Compare commits

..

8 Commits

Author SHA1 Message Date
YoVinchen 1905765821 fix: resolve omo clippy warnings and include app update 2026-02-12 01:23:24 +08:00
YoVinchen 651496eabb fix(omo): preserve custom fields and align otherFields import/validation 2026-02-12 01:14:30 +08:00
YoVinchen a301a81f7a Merge branch 'main' into fix/omo-lowercase-agent-keys 2026-02-11 23:10:51 +08:00
YoVinchen 3f50afbbf2 feat(omo): enrich preset model defaults and metadata fallback 2026-02-11 23:09:43 +08:00
YoVinchen b551bb92e2 feat(omo): replace model select with searchable combobox and improve fallback handling 2026-02-11 22:01:10 +08:00
YoVinchen 3e9b3d0031 feat(omo): add preset model variants for thinking level support
Add OPENCODE_PRESET_MODEL_VARIANTS constant with variant definitions
for Google, OpenAI, and Anthropic models. The omoModelVariantsMap
builder now falls back to presets when config-defined variants are
absent, enabling the variant selector for supported models.
2026-02-11 17:48:41 +08:00
YoVinchen d9f380d0fd feat(omo): add i18n support and tooltips for agent/category descriptions 2026-02-11 16:36:10 +08:00
YoVinchen 300abc31a7 fix(omo): use lowercase keys for builtin agent definitions
OMO config schema expects all agent keys to be lowercase.
Updated OMO_BUILTIN_AGENTS keys (Sisyphus → sisyphus, Hephaestus →
hephaestus, etc.) and aligned Rust test fixtures accordingly.
2026-02-10 23:47:43 +08:00
50 changed files with 1674 additions and 6006 deletions
-2
View File
@@ -746,7 +746,6 @@ dependencies = [
"tower-http 0.5.2",
"url",
"uuid",
"webkit2gtk",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -5634,7 +5633,6 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
dependencies = [
"indexmap 2.11.4",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
+1 -4
View File
@@ -35,7 +35,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
tauri-plugin-deep-link = "2"
dirs = "5.0"
toml = { version = "0.8", features = ["preserve_order"] }
toml = "0.8"
toml_edit = "0.22"
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
@@ -65,9 +65,6 @@ uuid = { version = "1.11", features = ["v4"] }
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
+1 -17
View File
@@ -339,20 +339,6 @@ pub struct CommonConfigSnippets {
}
impl CommonConfigSnippets {
/// 检查是否所有字段都为空
pub fn is_empty(&self) -> bool {
let is_blank = |value: &Option<String>| {
value
.as_ref()
.map(|snippet| snippet.trim().is_empty())
.unwrap_or(true)
};
is_blank(&self.claude)
&& is_blank(&self.codex)
&& is_blank(&self.gemini)
&& is_blank(&self.opencode)
}
/// 获取指定应用的通用配置片段
pub fn get(&self, app: &AppType) -> Option<&String> {
match app {
@@ -392,9 +378,7 @@ pub struct MultiAppConfig {
#[serde(default)]
pub skills: SkillStore,
/// 通用配置片段(按应用分治)
/// 注意:此字段主要用于从旧版 config.json 迁移数据到数据库
/// 迁移成功后会被清空,空时不写入 config.json
#[serde(default, skip_serializing_if = "CommonConfigSnippets::is_empty")]
#[serde(default)]
pub common_config_snippets: CommonConfigSnippets,
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
#[serde(default, skip_serializing_if = "Option::is_none")]
+2 -27
View File
@@ -204,36 +204,11 @@ pub async fn set_common_config_snippet(
) -> Result<(), String> {
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"gemini" => {
// 验证 ENV/JSON 格式并拒绝禁用键
let validation = crate::config_merge::validate_gemini_common_snippet(&snippet);
// 如果有禁用键,返回错误
if !validation.forbidden_keys_found.is_empty() {
return Err(format!(
"GEMINI_FORBIDDEN_KEYS:{}",
validation.forbidden_keys_found.join(",")
));
}
// 如果非空但解析后无有效内容,返回错误
if !validation.is_valid {
return Err("GEMINI_INVALID_SNIPPET".to_string());
}
}
"codex" => {
// TOML 格式,暂不验证(前端验证)
}
"omo" => {
// 验证 JSON 格式
"claude" | "gemini" | "omo" => {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"codex" => {}
_ => {}
}
}
-889
View File
@@ -1,889 +0,0 @@
//! Configuration merge utilities for the common config redesign.
//!
//! This module provides functions for:
//! - `compute_final_config`: Merges common config (base) with custom config (override)
//! - `extract_difference`: Extracts custom parts from live config by comparing with common config
//!
//! Supports JSON (Claude, Gemini) and TOML (Codex) formats.
use serde_json::{Map, Value as JsonValue};
use toml::Value as TomlValue;
// ============================================================================
// JSON Configuration Merge Functions
// ============================================================================
/// Deep merge two JSON objects where `source` overrides `target`.
///
/// Merge rules:
/// - Nested objects: Recursive merge
/// - Arrays: Source completely replaces target (no element-level merge)
/// - Primitives: Source overrides target
/// - Null values: Do not override
fn deep_merge_json(target: &mut JsonValue, source: &JsonValue) {
// First check if both are objects without destructuring
let both_objects =
matches!(target, JsonValue::Object(_)) && matches!(source, JsonValue::Object(_));
if both_objects {
// Safe to destructure now since we know both are objects
if let (JsonValue::Object(target_map), JsonValue::Object(source_map)) = (target, source) {
for (key, source_value) in source_map {
if source_value.is_null() {
// Null doesn't override
continue;
}
match target_map.get_mut(key) {
Some(target_value) if target_value.is_object() && source_value.is_object() => {
// Nested object: recursive merge
deep_merge_json(target_value, source_value);
}
_ => {
// Other cases: source overrides
target_map.insert(key.clone(), source_value.clone());
}
}
}
}
} else {
// Non-object: source overrides
*target = source.clone();
}
}
/// Compute final JSON config.
///
/// Common config as base, custom config overrides (custom takes priority).
///
/// # Arguments
/// * `custom_config` - Provider's custom configuration
/// * `common_config` - Common configuration snippet
/// * `enabled` - Whether common config is enabled
///
/// # Returns
/// The merged final configuration as JSON value
pub fn compute_final_json_config(
custom_config: &JsonValue,
common_config: &JsonValue,
enabled: bool,
) -> JsonValue {
if !enabled {
return custom_config.clone();
}
// Validate both are objects
let common_obj = match common_config {
JsonValue::Object(m) if !m.is_empty() => m,
_ => return custom_config.clone(),
};
let custom_obj = match custom_config {
JsonValue::Object(_) => custom_config,
_ => return custom_config.clone(),
};
// Start with common config as base
let mut result = JsonValue::Object(common_obj.clone());
// Merge custom config on top (custom overrides common)
deep_merge_json(&mut result, custom_obj);
result
}
/// Check if two JSON values are deeply equal.
fn json_deep_equal(a: &JsonValue, b: &JsonValue) -> bool {
match (a, b) {
(JsonValue::Null, JsonValue::Null) => true,
(JsonValue::Bool(a), JsonValue::Bool(b)) => a == b,
(JsonValue::Number(a), JsonValue::Number(b)) => a == b,
(JsonValue::String(a), JsonValue::String(b)) => a == b,
(JsonValue::Array(a), JsonValue::Array(b)) => {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(x, y)| json_deep_equal(x, y))
}
(JsonValue::Object(a), JsonValue::Object(b)) => {
if a.len() != b.len() {
return false;
}
a.iter()
.all(|(k, v)| b.get(k).is_some_and(|bv| json_deep_equal(v, bv)))
}
_ => false,
}
}
/// Extract difference between live config and common config.
///
/// Extraction rules:
/// - Keys not in common config → include in custom config
/// - Keys in common config but with different values → include in custom config (user override)
/// - Keys in common config with same values → skip (avoid redundant storage)
///
/// # Arguments
/// * `live_config` - Configuration read from live file
/// * `common_config` - Common configuration snippet
///
/// # Returns
/// Tuple of (custom_config, has_common_keys)
pub fn extract_json_difference(
live_config: &JsonValue,
common_config: &JsonValue,
) -> (JsonValue, bool) {
let live_obj = match live_config {
JsonValue::Object(m) => m,
_ => return (live_config.clone(), false),
};
let common_obj = match common_config {
JsonValue::Object(m) => m,
_ => return (live_config.clone(), false),
};
let mut custom_config = Map::new();
let mut has_common_keys = false;
fn extract_recursive(
live: &Map<String, JsonValue>,
common: &Map<String, JsonValue>,
target: &mut Map<String, JsonValue>,
has_common: &mut bool,
) {
for (key, live_value) in live {
match common.get(key) {
None => {
// Case 1: Key not in common config, keep it
target.insert(key.clone(), live_value.clone());
}
Some(common_value) => {
// Check if both are objects for nested handling
match (live_value, common_value) {
(JsonValue::Object(live_map), JsonValue::Object(common_map)) => {
// Case 2: Nested object, recurse
let mut nested = Map::new();
extract_recursive(live_map, common_map, &mut nested, has_common);
if !nested.is_empty() {
target.insert(key.clone(), JsonValue::Object(nested));
} else {
// Nested object matches common config
*has_common = true;
}
}
_ if !json_deep_equal(live_value, common_value) => {
// Case 3: Value different, keep it (user override)
target.insert(key.clone(), live_value.clone());
}
_ => {
// Case 4: Value same, skip (avoid redundancy)
*has_common = true;
}
}
}
}
}
}
extract_recursive(
live_obj,
common_obj,
&mut custom_config,
&mut has_common_keys,
);
(JsonValue::Object(custom_config), has_common_keys)
}
// ============================================================================
// TOML Configuration Merge Functions
// ============================================================================
/// Deep merge TOML tables while preserving target's key order (custom first, then common).
///
/// - Keeps target's existing values (target = custom config has priority)
/// - Only adds keys from source that don't exist in target (common-only keys)
/// - For nested tables, recursively merges while preserving target's keys first
fn deep_merge_toml_preserve_order(target: &mut TomlValue, source: &TomlValue) {
let both_tables =
matches!(target, TomlValue::Table(_)) && matches!(source, TomlValue::Table(_));
if both_tables {
if let (TomlValue::Table(target_map), TomlValue::Table(source_map)) = (target, source) {
for (key, source_value) in source_map {
match target_map.get_mut(key) {
Some(target_value) if target_value.is_table() && source_value.is_table() => {
// Nested table: recursive merge (preserving target's keys first)
deep_merge_toml_preserve_order(target_value, source_value);
}
Some(_) => {
// Key exists in target: keep target's value (custom overrides common)
}
None => {
// Key only in source: add to target (common-only keys appear after)
target_map.insert(key.clone(), source_value.clone());
}
}
}
}
}
// Non-table case: keep target as-is (custom has priority)
}
/// Compute final TOML config.
///
/// # Arguments
/// * `custom_config` - Provider's custom TOML configuration
/// * `common_config` - Common TOML configuration snippet
/// * `enabled` - Whether common config is enabled
///
/// # Returns
/// Tuple of (final_config_toml, error_message)
pub fn compute_final_toml_config_str(
custom_toml: &str,
common_toml: &str,
enabled: bool,
) -> (String, Option<String>) {
if !enabled || common_toml.trim().is_empty() {
return (custom_toml.to_string(), None);
}
// Check if common TOML has actual content (not just comments)
let common_has_content = common_toml.lines().any(|line| {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with('#')
});
if !common_has_content {
return (custom_toml.to_string(), None);
}
// Parse custom TOML
let custom_config: TomlValue = match custom_toml.parse() {
Ok(v) => v,
Err(_) if custom_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
Err(e) => {
return (
custom_toml.to_string(),
Some(format!("Failed to parse custom TOML: {e}")),
)
}
};
// Parse common TOML
let common_config: TomlValue = match common_toml.parse() {
Ok(v) => v,
Err(e) => {
return (
custom_toml.to_string(),
Some(format!("Failed to parse common TOML: {e}")),
)
}
};
// Start with custom config (so custom keys appear first in output)
let mut result = custom_config;
// Add common config keys that don't exist in custom (these appear after custom keys)
// Custom values always take priority (they're already in result)
deep_merge_toml_preserve_order(&mut result, &common_config);
// Serialize back to TOML string
match toml::to_string_pretty(&result) {
Ok(s) => (s, None),
Err(e) => (
custom_toml.to_string(),
Some(format!("Failed to serialize TOML: {e}")),
),
}
}
/// Check if two TOML values are deeply equal.
fn toml_deep_equal(a: &TomlValue, b: &TomlValue) -> bool {
match (a, b) {
(TomlValue::String(a), TomlValue::String(b)) => a == b,
(TomlValue::Integer(a), TomlValue::Integer(b)) => a == b,
(TomlValue::Float(a), TomlValue::Float(b)) => (a - b).abs() < f64::EPSILON,
(TomlValue::Boolean(a), TomlValue::Boolean(b)) => a == b,
(TomlValue::Datetime(a), TomlValue::Datetime(b)) => a == b,
(TomlValue::Array(a), TomlValue::Array(b)) => {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(x, y)| toml_deep_equal(x, y))
}
(TomlValue::Table(a), TomlValue::Table(b)) => {
if a.len() != b.len() {
return false;
}
a.iter()
.all(|(k, v)| b.get(k).is_some_and(|bv| toml_deep_equal(v, bv)))
}
_ => false,
}
}
/// Extract difference between live TOML config and common config.
///
/// # Returns
/// Tuple of (custom_toml, has_common_keys, error_message)
pub fn extract_toml_difference_str(
live_toml: &str,
common_toml: &str,
) -> (String, bool, Option<String>) {
if common_toml.trim().is_empty() {
return (live_toml.to_string(), false, None);
}
// Check if common TOML has actual content
let common_has_content = common_toml.lines().any(|line| {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with('#')
});
if !common_has_content {
return (live_toml.to_string(), false, None);
}
// Parse live TOML
let live_config: TomlValue = match live_toml.parse() {
Ok(v) => v,
Err(_) if live_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
Err(e) => {
return (
live_toml.to_string(),
false,
Some(format!("Failed to parse live TOML: {e}")),
)
}
};
// Parse common TOML
let common_config: TomlValue = match common_toml.parse() {
Ok(v) => v,
Err(e) => {
return (
live_toml.to_string(),
false,
Some(format!("Failed to parse common TOML: {e}")),
)
}
};
let live_table = match &live_config {
TomlValue::Table(m) => m,
_ => return (live_toml.to_string(), false, None),
};
let common_table = match &common_config {
TomlValue::Table(m) => m,
_ => return (live_toml.to_string(), false, None),
};
let mut custom_table = toml::map::Map::new();
let mut has_common_keys = false;
fn extract_recursive_toml(
live: &toml::map::Map<String, TomlValue>,
common: &toml::map::Map<String, TomlValue>,
target: &mut toml::map::Map<String, TomlValue>,
has_common: &mut bool,
) {
for (key, live_value) in live {
match common.get(key) {
None => {
target.insert(key.clone(), live_value.clone());
}
Some(common_value) => match (live_value, common_value) {
(TomlValue::Table(live_map), TomlValue::Table(common_map)) => {
let mut nested = toml::map::Map::new();
extract_recursive_toml(live_map, common_map, &mut nested, has_common);
if !nested.is_empty() {
target.insert(key.clone(), TomlValue::Table(nested));
} else {
*has_common = true;
}
}
_ if !toml_deep_equal(live_value, common_value) => {
target.insert(key.clone(), live_value.clone());
}
_ => {
*has_common = true;
}
},
}
}
}
extract_recursive_toml(
live_table,
common_table,
&mut custom_table,
&mut has_common_keys,
);
let custom_config = TomlValue::Table(custom_table);
match toml::to_string_pretty(&custom_config) {
Ok(s) if s.trim().is_empty() => (String::new(), has_common_keys, None),
Ok(s) => (s, has_common_keys, None),
Err(e) => (
live_toml.to_string(),
false,
Some(format!("Failed to serialize TOML: {e}")),
),
}
}
// ============================================================================
// Live Config Merge for Provider Sync
// ============================================================================
use crate::app_config::AppType;
use crate::provider::{Provider, ProviderMeta};
/// Result of merging common config with provider's custom config.
#[derive(Debug)]
pub struct MergeResult {
/// The final merged configuration
pub config: JsonValue,
/// Warning message if any (e.g., parse errors that were recovered from)
pub warning: Option<String>,
}
/// Check if common config is enabled for a provider and app type.
///
/// Priority:
/// 1. `meta.common_config_enabled_by_app.{app_type}` (per-app setting)
/// 2. `meta.common_config_enabled` (global setting)
/// 3. `false` (default)
pub fn is_common_config_enabled(meta: Option<&ProviderMeta>, app_type: &AppType) -> bool {
meta.and_then(|m| {
m.common_config_enabled_by_app
.as_ref()
.and_then(|by_app| match app_type {
AppType::Claude => by_app.claude,
AppType::Codex => by_app.codex,
AppType::Gemini => by_app.gemini,
AppType::OpenCode => by_app.opencode,
})
.or(m.common_config_enabled)
})
.unwrap_or(false)
}
/// Merge common config with provider's custom config for live file writing.
///
/// This is the single source of truth for common config merging logic.
/// Used by both `live.rs` and `proxy.rs`.
///
/// # Arguments
/// * `app_type` - The application type (Claude, Codex, Gemini, OpenCode)
/// * `provider` - The provider whose config is being merged
/// * `common_snippet` - The common config snippet from database (may be empty)
///
/// # Returns
/// `MergeResult` containing the final config and optional warning
pub fn merge_config_for_live(
app_type: &AppType,
provider: &Provider,
common_snippet: Option<&str>,
) -> MergeResult {
// Check if common config is enabled
let enabled = is_common_config_enabled(provider.meta.as_ref(), app_type);
// If not enabled or snippet is empty, return original config
let snippet = match common_snippet {
Some(s) if enabled && !s.trim().is_empty() => s,
_ => {
return MergeResult {
config: provider.settings_config.clone(),
warning: None,
}
}
};
// Perform merge based on app type
match app_type {
AppType::Claude => merge_claude_config(&provider.settings_config, snippet),
AppType::Codex => merge_codex_config(&provider.settings_config, snippet),
AppType::Gemini => merge_gemini_config(&provider.settings_config, snippet),
AppType::OpenCode => {
// OpenCode doesn't support common config merge
MergeResult {
config: provider.settings_config.clone(),
warning: None,
}
}
}
}
/// Merge Claude config (JSON format).
fn merge_claude_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
let common_value: JsonValue = match serde_json::from_str(common_snippet) {
Ok(v) => v,
Err(e) => {
// Return warning without logging - caller will log if needed
return MergeResult {
config: custom_config.clone(),
warning: Some(format!("COMMON_CONFIG_PARSE_ERROR: {e}")),
};
}
};
MergeResult {
config: compute_final_json_config(custom_config, &common_value, true),
warning: None,
}
}
/// Merge Codex config (TOML for config field, JSON for auth field).
fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
let mut merged_config = custom_config.clone();
let mut warning = None;
if let Some(obj) = merged_config.as_object_mut() {
if let Some(config_str) = obj.get("config").and_then(|v| v.as_str()) {
let (merged_toml, error) =
compute_final_toml_config_str(config_str, common_snippet, true);
if let Some(e) = error {
// Return warning without logging - caller will log if needed
warning = Some(format!("CODEX_TOML_MERGE_ERROR: {e}"));
}
obj.insert("config".to_string(), JsonValue::String(merged_toml));
}
}
MergeResult {
config: merged_config,
warning,
}
}
/// Merge Gemini config (JSON format for env field).
///
/// Gemini common config can be stored in three formats:
/// - ENV format: KEY=VALUE lines (one per line)
/// - Flat JSON: `{"KEY": "VALUE", ...}`
/// - Wrapped JSON: `{"env": {"KEY": "VALUE", ...}}`
///
/// This function supports all formats for backward compatibility.
fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
// Parse and validate common config (filters forbidden keys)
let validation = validate_gemini_common_snippet(common_snippet);
let common_env = validation.env;
// Generate warning if forbidden keys were found
let warning = if !validation.forbidden_keys_found.is_empty() {
Some(format!(
"GEMINI_FORBIDDEN_KEYS:{}",
validation.forbidden_keys_found.join(",")
))
} else {
None
};
if common_env.is_empty() {
return MergeResult {
config: custom_config.clone(),
warning,
};
}
let mut merged_config = custom_config.clone();
// Merge only the env field
// If custom config has env, merge common into it; otherwise initialize with common env
if let Some(merged_obj) = merged_config.as_object_mut() {
if let Some(merged_env) = merged_obj.get_mut("env") {
if let Some(merged_env_obj) = merged_env.as_object_mut() {
// Common env as base, custom env overrides
let mut final_env = common_env;
for (k, v) in merged_env_obj.iter() {
final_env.insert(k.clone(), v.clone());
}
*merged_env = JsonValue::Object(final_env);
}
} else if !common_env.is_empty() {
// Custom config has no env field - initialize with common env
merged_obj.insert("env".to_string(), JsonValue::Object(common_env));
}
}
MergeResult {
config: merged_config,
warning,
}
}
/// Parse Gemini common config snippet supporting multiple formats.
///
/// Formats supported:
/// - ENV format: KEY=VALUE lines
/// - Flat JSON: {"KEY": "VALUE", ...}
/// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
pub(crate) fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map<String, JsonValue> {
let trimmed = snippet.trim();
if trimmed.is_empty() {
return serde_json::Map::new();
}
// Try JSON first
if let Ok(parsed) = serde_json::from_str::<JsonValue>(trimmed) {
if let Some(obj) = parsed.as_object() {
// Check if it's wrapped format {"env": {...}}
if let Some(env_value) = obj.get("env").and_then(|v| v.as_object()) {
return env_value.clone();
}
// Flat format
return obj.clone();
}
}
// Parse as ENV format (KEY=VALUE lines)
let mut result = serde_json::Map::new();
for line in trimmed.lines() {
let line_trimmed = line.trim();
if line_trimmed.is_empty() || line_trimmed.starts_with('#') {
continue;
}
if let Some(equal_index) = line_trimmed.find('=') {
let key = line_trimmed[..equal_index].trim();
let raw_value = line_trimmed[equal_index + 1..].trim();
// Strip surrounding quotes (single or double) from value
// e.g., KEY="value" or KEY='value' -> value
let value = strip_env_quotes(raw_value);
if !key.is_empty() {
result.insert(key.to_string(), JsonValue::String(value.to_string()));
}
}
}
result
}
/// Gemini common config forbidden keys - these should never be in common config
/// as they are provider-specific credentials/endpoints.
const GEMINI_FORBIDDEN_KEYS: &[&str] = &["GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"];
/// Result of validating Gemini common config snippet
#[derive(Debug, Clone)]
pub struct GeminiSnippetValidation {
/// Parsed environment variables (with forbidden keys filtered out)
pub env: serde_json::Map<String, JsonValue>,
/// List of forbidden keys that were found and filtered
pub forbidden_keys_found: Vec<String>,
/// Whether the snippet is valid (parseable and non-empty after filtering)
pub is_valid: bool,
}
/// Parse and validate Gemini common config snippet.
///
/// This function:
/// 1. Parses the snippet (supports ENV/JSON formats)
/// 2. Filters out forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY)
/// 3. Returns validation result with filtered env and forbidden keys found
pub fn validate_gemini_common_snippet(snippet: &str) -> GeminiSnippetValidation {
let raw_env = parse_gemini_common_snippet(snippet);
let mut filtered_env = serde_json::Map::new();
let mut forbidden_keys_found = Vec::new();
for (key, value) in raw_env {
if GEMINI_FORBIDDEN_KEYS.contains(&key.as_str()) {
forbidden_keys_found.push(key);
} else {
filtered_env.insert(key, value);
}
}
GeminiSnippetValidation {
is_valid: !filtered_env.is_empty() || snippet.trim().is_empty(),
env: filtered_env,
forbidden_keys_found,
}
}
/// Strip surrounding quotes from ENV value.
/// Supports both single and double quotes: "value" -> value, 'value' -> value
fn strip_env_quotes(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return &s[1..s.len() - 1];
}
}
s
}
// ============================================================================
// Unit Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_compute_final_json_config_disabled() {
let custom = json!({"a": 1, "b": 2});
let common = json!({"c": 3});
let result = compute_final_json_config(&custom, &common, false);
assert_eq!(result, custom);
}
#[test]
fn test_compute_final_json_config_enabled() {
let custom = json!({"a": 1, "b": 2});
let common = json!({"b": 99, "c": 3});
let result = compute_final_json_config(&custom, &common, true);
// custom overrides common, so b should be 2
assert_eq!(result["a"], 1);
assert_eq!(result["b"], 2); // custom wins
assert_eq!(result["c"], 3); // from common
}
#[test]
fn test_compute_final_json_config_nested() {
let custom = json!({
"env": {
"API_KEY": "custom-key",
"CUSTOM_VAR": "value"
}
});
let common = json!({
"env": {
"API_KEY": "common-key",
"SHARED_VAR": "shared"
},
"includeCoAuthoredBy": false
});
let result = compute_final_json_config(&custom, &common, true);
assert_eq!(result["env"]["API_KEY"], "custom-key"); // custom wins
assert_eq!(result["env"]["CUSTOM_VAR"], "value"); // from custom
assert_eq!(result["env"]["SHARED_VAR"], "shared"); // from common
assert_eq!(result["includeCoAuthoredBy"], false); // from common
}
#[test]
fn test_extract_json_difference() {
let live = json!({
"env": {
"API_KEY": "my-key",
"SHARED_VAR": "shared"
},
"includeCoAuthoredBy": false,
"custom_field": true
});
let common = json!({
"env": {
"SHARED_VAR": "shared"
},
"includeCoAuthoredBy": false
});
let (custom, has_common) = extract_json_difference(&live, &common);
// Should keep API_KEY (not in common) and custom_field
assert_eq!(custom["env"]["API_KEY"], "my-key");
assert_eq!(custom["custom_field"], true);
// Should NOT have SHARED_VAR or includeCoAuthoredBy (same as common)
assert!(custom["env"].get("SHARED_VAR").is_none());
assert!(custom.get("includeCoAuthoredBy").is_none());
assert!(has_common);
}
#[test]
fn test_extract_json_difference_with_override() {
let live = json!({
"includeCoAuthoredBy": true, // Different from common!
"shared": "value"
});
let common = json!({
"includeCoAuthoredBy": false,
"shared": "value"
});
let (custom, has_common) = extract_json_difference(&live, &common);
// Should keep includeCoAuthoredBy because value is different
assert_eq!(custom["includeCoAuthoredBy"], true);
// Should NOT have shared (same as common)
assert!(custom.get("shared").is_none());
assert!(has_common);
}
#[test]
fn test_compute_final_toml_config() {
let custom = r#"
model = "custom-model"
[custom_section]
key = "value"
"#;
let common = r#"
model = "common-model"
shared_key = "shared"
"#;
let (result, error) = compute_final_toml_config_str(custom, common, true);
assert!(error.is_none());
assert!(result.contains("custom-model")); // custom wins
assert!(result.contains("shared_key")); // from common
// With preserve_order feature enabled, verify key ordering via parsing
// instead of relying on string position (which is fragile)
let parsed: TomlValue = result.parse().expect("result should be valid TOML");
let table = parsed.as_table().expect("result should be a table");
let keys: Vec<&String> = table.keys().collect();
// Custom keys should appear before common-only keys
let model_idx = keys.iter().position(|k| *k == "model");
let custom_section_idx = keys.iter().position(|k| *k == "custom_section");
let shared_key_idx = keys.iter().position(|k| *k == "shared_key");
assert!(model_idx.is_some(), "model key should exist in result");
assert!(
custom_section_idx.is_some(),
"custom_section key should exist in result"
);
assert!(
shared_key_idx.is_some(),
"shared_key key should exist in result"
);
// With preserve_order, custom keys (model, custom_section) should come before common-only keys (shared_key)
assert!(
model_idx.unwrap() < shared_key_idx.unwrap(),
"custom 'model' should appear before common-only 'shared_key' (got model_idx={:?}, shared_key_idx={:?})",
model_idx, shared_key_idx
);
}
#[test]
fn test_extract_toml_difference() {
let live = r#"
model = "my-model"
shared_key = "shared"
[custom_section]
key = "value"
"#;
let common = r#"
shared_key = "shared"
"#;
let (custom, has_common, error) = extract_toml_difference_str(live, common);
assert!(error.is_none());
assert!(custom.contains("model")); // not in common
assert!(custom.contains("custom_section")); // not in common
assert!(!custom.contains("shared_key")); // same as common
assert!(has_common);
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ impl Database {
/// 获取整流器配置
///
/// 返回整流器配置,如果不存在则返回默认值(全部启)
/// 返回整流器配置,如果不存在则返回默认值(全部启
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
match self.get_setting("rectifier_config")? {
Some(json) => serde_json::from_str(&json)
+45 -55
View File
@@ -180,68 +180,58 @@ fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
}
/// Build provider meta with usage script configuration
///
/// Note: Deeplink imported providers have common config disabled by default
/// to avoid unexpected configuration merging.
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided
let has_usage_fields = request.usage_script.is_some()
|| request.usage_enabled.is_some()
|| request.usage_api_key.is_some()
|| request.usage_base_url.is_some()
|| request.usage_access_token.is_some()
|| request.usage_user_id.is_some()
|| request.usage_auto_interval.is_some();
if request.usage_script.is_none()
&& request.usage_enabled.is_none()
&& request.usage_api_key.is_none()
&& request.usage_base_url.is_none()
&& request.usage_access_token.is_none()
&& request.usage_user_id.is_none()
&& request.usage_auto_interval.is_none()
{
return Ok(None);
}
// Build usage script if fields are provided
let usage_script = if has_usage_fields {
// Decode usage script code if provided
let code = if let Some(script_b64) = &request.usage_script {
let decoded = decode_base64_param("usage_script", script_b64)?;
String::from_utf8(decoded).map_err(|e| {
AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}"))
})?
} else {
String::new()
};
// Determine enabled state: explicit param > has code > false
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults
// Note: use primary endpoint only (first one if comma-separated)
Some(UsageScript {
enabled,
language: "javascript".to_string(),
code,
timeout: Some(10),
api_key: request
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request.usage_base_url.clone().or_else(|| {
let primary = get_primary_endpoint(request);
if primary.is_empty() {
None
} else {
Some(primary)
}
}),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval,
})
// Decode usage script code if provided
let code = if let Some(script_b64) = &request.usage_script {
let decoded = decode_base64_param("usage_script", script_b64)?;
String::from_utf8(decoded)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
} else {
None
String::new()
};
// Determine enabled state: explicit param > has code > false
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults
// Note: use primary endpoint only (first one if comma-separated)
let usage_script = UsageScript {
enabled,
language: "javascript".to_string(),
code,
timeout: Some(10),
api_key: request
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request.usage_base_url.clone().or_else(|| {
let primary = get_primary_endpoint(request);
if primary.is_empty() {
None
} else {
Some(primary)
}
}),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval,
};
// Always return a ProviderMeta with common_config_enabled = false for deeplink imports
// This ensures the imported provider uses its own settings_config directly
// without merging with the global common config snippet
Ok(Some(ProviderMeta {
usage_script,
common_config_enabled: Some(false),
usage_script: Some(usage_script),
..Default::default()
}))
}
-16
View File
@@ -6,7 +6,6 @@ mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod config_merge;
mod database;
mod deeplink;
mod error;
@@ -769,21 +768,6 @@ pub fn run() {
restore_proxy_state_on_startup(&state).await;
});
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
#[cfg(target_os = "linux")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.with_webview(|webview| {
use webkit2gtk::{WebViewExt, SettingsExt, HardwareAccelerationPolicy};
let wk_webview = webview.inner();
if let Some(settings) = WebViewExt::settings(&wk_webview) {
SettingsExt::set_hardware_acceleration_policy(&settings, HardwareAccelerationPolicy::Never);
log::info!("已禁用 WebKitGTK 硬件加速");
}
});
}
}
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
+1 -26
View File
@@ -191,19 +191,6 @@ pub struct ProviderProxyConfig {
pub proxy_password: Option<String>,
}
/// 通用配置启用状态(按应用)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommonConfigEnabledByApp {
#[serde(skip_serializing_if = "Option::is_none")]
pub claude: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codex: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gemini: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub opencode: Option<bool>,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
@@ -243,18 +230,6 @@ pub struct ProviderMeta {
/// 供应商单独的代理配置
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>,
/// 是否启用通用配置片段(用于跨供应商保持勾选状态)
#[serde(
rename = "commonConfigEnabled",
skip_serializing_if = "Option::is_none"
)]
pub common_config_enabled: Option<bool>,
/// 按应用记录通用配置启用状态(优先于 commonConfigEnabled
#[serde(
rename = "commonConfigEnabledByApp",
skip_serializing_if = "Option::is_none"
)]
pub common_config_enabled_by_app: Option<CommonConfigEnabledByApp>,
/// Claude API 格式(仅 Claude 供应商使用)
/// - "anthropic": 原生 Anthropic Messages API,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
@@ -656,7 +631,7 @@ pub struct OpenCodeModelLimit {
}
#[cfg(test)]
mod provider_tests {
mod tests {
use super::{
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
ProviderManager, ProviderMeta, UniversalProvider,
+22 -213
View File
@@ -8,10 +8,7 @@ use super::{
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
thinking_rectifier::{
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
types::{ProxyStatus, RectifierConfig},
ProxyError,
};
@@ -160,7 +157,6 @@ impl RequestForwarder {
// 整流器重试标记:确保整流最多触发一次
let mut rectifier_retried = false;
let mut budget_rectifier_retried = false;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
@@ -262,7 +258,6 @@ impl RequestForwarder {
provider_type,
ProviderType::Claude | ProviderType::ClaudeAuth
);
let mut signature_rectifier_non_retryable_client_error = false;
if is_anthropic_provider {
let error_message = extract_error_message(&e);
@@ -298,185 +293,12 @@ impl RequestForwarder {
// 首次触发:整流请求体
let rectified = rectify_anthropic_request(&mut body);
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
// 整流未生效:直接返回错误(不可重试客户端错误)
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回"
);
signature_rectifier_non_retryable_client_error = true;
} else {
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str()
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
.await;
});
}
if status.total_requests > 0 {
status.success_rate = (status.success_requests
as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_)
| ProxyError::ForwardFailed(_) => true,
ProxyError::UpstreamError { status, .. } => {
*status >= 500
}
_ => false,
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(retry_err.to_string()),
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
}
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(retry_err.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: retry_err,
provider: Some(provider.clone()),
});
}
}
}
}
}
// 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商)
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_budget(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if budget_rectifier_retried {
log::warn!(
"[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试"
);
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
let budget_rectified = rectify_thinking_budget(&mut body);
if !budget_rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
);
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
@@ -499,13 +321,15 @@ impl RequestForwarder {
}
log::info!(
"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}",
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
budget_rectified.before,
budget_rectified.after
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
@@ -513,7 +337,8 @@ impl RequestForwarder {
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
@@ -525,6 +350,7 @@ impl RequestForwarder {
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
@@ -534,6 +360,7 @@ impl RequestForwarder {
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
@@ -543,11 +370,14 @@ impl RequestForwarder {
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
@@ -567,10 +397,12 @@ impl RequestForwarder {
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
true
@@ -580,6 +412,7 @@ impl RequestForwarder {
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
@@ -591,6 +424,7 @@ impl RequestForwarder {
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
@@ -617,28 +451,6 @@ impl RequestForwarder {
}
}
if signature_rectifier_non_retryable_client_error {
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
// 失败:记录失败并更新熔断器
let _ = self
.router
@@ -763,9 +575,6 @@ impl RequestForwarder {
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mapped_body = normalize_thinking_type(mapped_body);
// 转换请求体(如果需要)
let request_body = if needs_transform {
adapter.transform_request(mapped_body, provider)?
-1
View File
@@ -21,7 +21,6 @@ pub mod response_handler;
pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub mod thinking_budget_rectifier;
pub mod thinking_rectifier;
pub(crate) mod types;
pub mod usage;
+2 -36
View File
@@ -97,21 +97,11 @@ impl ModelMapping {
/// 检测请求是否启用了 thinking 模式
pub fn has_thinking_enabled(body: &Value) -> bool {
match body
.get("thinking")
body.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
{
Some("enabled") | Some("adaptive") => true,
Some("disabled") | None => false,
Some(other) => {
log::warn!(
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
);
false
}
}
== Some("enabled")
}
/// 对请求体应用模型映射
@@ -310,30 +300,6 @@ mod tests {
assert!(mapped.is_none());
}
#[test]
fn test_thinking_adaptive() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "adaptive"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_thinking_unknown_type() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "some_future_type"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_case_insensitive() {
let provider = create_provider_with_mapping();
@@ -1,359 +0,0 @@
//! Thinking Budget 整流器
//!
//! 用于自动修复 Anthropic API 中因 thinking budget 约束导致的请求错误。
//! 当上游 API 返回 budget_tokens 相关错误时,系统会自动调整 budget 参数并重试。
use super::types::RectifierConfig;
use serde_json::Value;
/// 最大 thinking budget tokens
const MAX_THINKING_BUDGET: u64 = 32000;
/// 最大 max_tokens 值
const MAX_TOKENS_VALUE: u64 = 64000;
/// max_tokens 必须大于 budget_tokens
const MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1;
/// Budget 整流结果
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BudgetRectifySnapshot {
/// max_tokens
pub max_tokens: Option<u64>,
/// thinking.type
pub thinking_type: Option<String>,
/// thinking.budget_tokens
pub thinking_budget_tokens: Option<u64>,
}
/// Budget 整流结果
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BudgetRectifyResult {
/// 是否应用了整流
pub applied: bool,
/// 整流前快照
pub before: BudgetRectifySnapshot,
/// 整流后快照
pub after: BudgetRectifySnapshot,
}
/// 检测是否需要触发 thinking budget 整流器
///
/// 检测条件:error message 同时包含 `budget_tokens` + `thinking` 相关约束
pub fn should_rectify_thinking_budget(
error_message: Option<&str>,
config: &RectifierConfig,
) -> bool {
// 检查总开关
if !config.enabled {
return false;
}
// 检查子开关
if !config.request_thinking_budget {
return false;
}
let Some(msg) = error_message else {
return false;
};
let lower = msg.to_lowercase();
// 与 CCH 对齐:仅在包含 budget_tokens + thinking + 1024 约束时触发
let has_budget_tokens_reference =
lower.contains("budget_tokens") || lower.contains("budget tokens");
let has_thinking_reference = lower.contains("thinking");
let has_1024_constraint = lower.contains("greater than or equal to 1024")
|| lower.contains(">= 1024")
|| (lower.contains("1024") && lower.contains("input should be"));
if has_budget_tokens_reference && has_thinking_reference && has_1024_constraint {
return true;
}
false
}
/// 对请求体执行 budget 整流
///
/// 整流动作:
/// - `thinking.type = "enabled"`
/// - `thinking.budget_tokens = 32000`
/// - 如果 `max_tokens < 32001`,设为 `64000`
pub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult {
let before = snapshot_budget(body);
// 与 CCH 对齐:adaptive 请求不改写
if before.thinking_type.as_deref() == Some("adaptive") {
return BudgetRectifyResult {
applied: false,
before: before.clone(),
after: before,
};
}
// 与 CCH 对齐:缺少/非法 thinking 时自动创建后再整流
if !body.get("thinking").is_some_and(Value::is_object) {
body["thinking"] = Value::Object(serde_json::Map::new());
}
let Some(thinking) = body.get_mut("thinking").and_then(|t| t.as_object_mut()) else {
return BudgetRectifyResult {
applied: false,
before: before.clone(),
after: before,
};
};
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
thinking.insert(
"budget_tokens".to_string(),
Value::Number(MAX_THINKING_BUDGET.into()),
);
if before.max_tokens.is_none() || before.max_tokens < Some(MIN_MAX_TOKENS_FOR_BUDGET) {
body["max_tokens"] = Value::Number(MAX_TOKENS_VALUE.into());
}
let after = snapshot_budget(body);
BudgetRectifyResult {
applied: before != after,
before,
after,
}
}
fn snapshot_budget(body: &Value) -> BudgetRectifySnapshot {
let max_tokens = body.get("max_tokens").and_then(|v| v.as_u64());
let thinking = body.get("thinking").and_then(|t| t.as_object());
let thinking_type = thinking
.and_then(|t| t.get("type"))
.and_then(|v| v.as_str())
.map(ToString::to_string);
let thinking_budget_tokens = thinking
.and_then(|t| t.get("budget_tokens"))
.and_then(|v| v.as_u64());
BudgetRectifySnapshot {
max_tokens,
thinking_type,
thinking_budget_tokens,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn enabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
fn budget_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: false,
}
}
fn master_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
// ==================== should_rectify_thinking_budget 测试 ====================
#[test]
fn test_detect_budget_tokens_thinking_error() {
assert!(should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_max_tokens_error() {
assert!(!should_rectify_thinking_budget(
Some("budget_tokens must be less than max_tokens"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_1024_error() {
assert!(!should_rectify_thinking_budget(
Some("budget_tokens: value must be at least 1024"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_with_thinking_and_1024_error() {
assert!(should_rectify_thinking_budget(
Some("thinking budget_tokens must be >= 1024"),
&enabled_config()
));
}
#[test]
fn test_no_trigger_for_unrelated_error() {
assert!(!should_rectify_thinking_budget(
Some("Request timeout"),
&enabled_config()
));
assert!(!should_rectify_thinking_budget(None, &enabled_config()));
}
#[test]
fn test_disabled_budget_config() {
assert!(!should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&budget_disabled_config()
));
}
#[test]
fn test_master_disabled() {
assert!(!should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&master_disabled_config()
));
}
// ==================== rectify_thinking_budget 测试 ====================
#[test]
fn test_rectify_budget_basic() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.thinking_type.as_deref(), Some("enabled"));
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(result.before.thinking_budget_tokens, Some(512));
assert_eq!(
result.after.thinking_budget_tokens,
Some(MAX_THINKING_BUDGET)
);
assert_eq!(result.before.max_tokens, Some(1024));
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_skips_adaptive() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert_eq!(result.before, result.after);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["budget_tokens"], 512);
assert_eq!(body["max_tokens"], 1024);
}
#[test]
fn test_rectify_budget_preserves_large_max_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 },
"max_tokens": 100000
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.max_tokens, Some(100000));
assert_eq!(result.after.max_tokens, Some(100000));
assert_eq!(body["max_tokens"], 100000);
}
#[test]
fn test_rectify_budget_creates_thinking_object_when_missing() {
let mut body = json!({
"model": "claude-test",
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.thinking_type, None);
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(
result.after.thinking_budget_tokens,
Some(MAX_THINKING_BUDGET)
);
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_no_max_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 }
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.max_tokens, None);
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_normalizes_non_enabled_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "disabled", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.thinking_type.as_deref(), Some("disabled"));
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_no_change_when_already_valid() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 32000 },
"max_tokens": 64001
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert_eq!(result.before, result.after);
assert_eq!(body["thinking"]["budget_tokens"], 32000);
assert_eq!(body["max_tokens"], 64001);
}
}
+3 -271
View File
@@ -59,12 +59,10 @@ pub fn should_rectify_thinking_signature(
}
// 场景3: expected thinking or redacted_thinking, found tool_use
// 与 CCH 对齐:要求明确包含 tool_use,避免过宽匹配。
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
if lower.contains("expected")
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("found")
&& lower.contains("tool_use")
{
return true;
}
@@ -75,28 +73,6 @@ pub fn should_rectify_thinking_signature(
return true;
}
// 场景5: signature 字段不被接受(第三方渠道)
// 错误示例: "xxx.signature: Extra inputs are not permitted"
if lower.contains("signature") && lower.contains("extra inputs are not permitted") {
return true;
}
// 场景6: thinking/redacted_thinking 块被修改
// 错误示例: "thinking or redacted_thinking blocks ... cannot be modified"
if (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("cannot be modified")
{
return true;
}
// 场景7: 非法请求(与 CCH 对齐,按 invalid request 统一兜底)
if lower.contains("非法请求")
|| lower.contains("illegal request")
|| lower.contains("invalid request")
{
return true;
}
false
}
@@ -183,13 +159,11 @@ pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
/// 判断是否需要删除顶层 thinking 字段
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
// 检查 thinking 是否启用
let thinking_type = body
let thinking_enabled = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str());
// 与 CCH 对齐:仅 type=enabled 视为开启
let thinking_enabled = thinking_type == Some("enabled");
.and_then(|t| t.as_str())
== Some("enabled");
if !thinking_enabled {
return false;
@@ -228,11 +202,6 @@ fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
}
/// 与 CCH 对齐:请求前不做 thinking type 主动改写。
pub fn normalize_thinking_type(body: Value) -> Value {
body
}
#[cfg(test)]
mod tests {
use super::*;
@@ -242,7 +211,6 @@ mod tests {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
@@ -250,7 +218,6 @@ mod tests {
RectifierConfig {
enabled: true,
request_thinking_signature: false,
request_thinking_budget: false,
}
}
@@ -258,7 +225,6 @@ mod tests {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
@@ -298,14 +264,6 @@ mod tests {
));
}
#[test]
fn test_no_detect_thinking_expected_without_tool_use() {
assert!(!should_rectify_thinking_signature(
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`."),
&enabled_config()
));
}
#[test]
fn test_detect_must_start_with_thinking() {
assert!(should_rectify_thinking_signature(
@@ -460,230 +418,4 @@ mod tests {
// 此时会触发删除顶层 thinking 的逻辑
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
}
// ==================== 新增错误场景检测测试 ====================
#[test]
fn test_detect_signature_extra_inputs() {
// 场景5: signature 字段不被接受
assert!(should_rectify_thinking_signature(
Some("xxx.signature: Extra inputs are not permitted"),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_cannot_be_modified() {
// 场景6: thinking blocks cannot be modified
assert!(should_rectify_thinking_signature(
Some("thinking or redacted_thinking blocks in the response cannot be modified"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_request() {
// 场景7: 非法请求(与 CCH 对齐,统一触发)
assert!(should_rectify_thinking_signature(
Some("非法请求:thinking signature 不合法"),
&enabled_config()
));
assert!(should_rectify_thinking_signature(
Some("illegal request: tool_use block mismatch"),
&enabled_config()
));
assert!(should_rectify_thinking_signature(
Some("invalid request: malformed JSON"),
&enabled_config()
));
}
#[test]
fn test_do_not_detect_thinking_type_tag_mismatch() {
// 与 CCH 对齐:adaptive tag mismatch 不触发签名整流器
assert!(!should_rectify_thinking_signature(
Some("Input tag 'adaptive' found using 'type' does not match expected tags"),
&enabled_config()
));
}
// ==================== adaptive thinking type 测试 ====================
#[test]
fn test_rectify_keeps_adaptive_when_no_legacy_blocks() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
assert!(body["thinking"].get("budget_tokens").is_none());
}
#[test]
fn test_rectify_adaptive_preserves_existing_budget_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 5000 },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["budget_tokens"], 5000);
}
#[test]
fn test_rectify_does_not_change_enabled_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 1024 },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "enabled");
}
#[test]
fn test_rectify_removes_top_level_thinking_adaptive() {
// 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除,adaptive 不删除
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
]
}, {
"role": "user",
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
}
#[test]
fn test_rectify_adaptive_still_cleans_legacy_signature_blocks() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "t", "signature": "sig_thinking" },
{ "type": "text", "text": "hello", "signature": "sig_text" }
]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "text");
assert!(content[0].get("signature").is_none());
assert_eq!(body["thinking"]["type"], "adaptive");
}
// ==================== normalize_thinking_type 测试 ====================
#[test]
fn test_normalize_thinking_type_adaptive_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "adaptive");
assert!(result["thinking"].get("budget_tokens").is_none());
}
#[test]
fn test_normalize_thinking_type_enabled_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 2048 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 2048);
}
#[test]
fn test_normalize_thinking_type_disabled_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "disabled" }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "disabled");
}
#[test]
fn test_normalize_thinking_type_preserves_budget() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 5000 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "adaptive");
assert_eq!(result["thinking"]["budget_tokens"], 5000);
}
#[test]
fn test_normalize_thinking_type_no_thinking() {
let body = json!({
"model": "claude-test"
});
let result = normalize_thinking_type(body);
assert!(result.get("thinking").is_none());
}
#[test]
fn test_normalize_thinking_type_unknown_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "unexpected", "budget_tokens": 100 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "unexpected");
assert_eq!(result["thinking"]["budget_tokens"], 100);
}
}
+14 -46
View File
@@ -195,22 +195,17 @@ pub struct AppProxyConfig {
/// 整流器配置
///
/// 存储在 settings 表中
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RectifierConfig {
/// 总开关:是否启用整流器(默认开启)
#[serde(default = "default_true")]
/// 总开关:是否启用整流器
#[serde(default)]
pub enabled: bool,
/// 请求整流:启用 thinking 签名整流器(默认开启)
/// 请求整流:启用 thinking 签名整流器
///
/// 处理错误:Invalid 'signature' in 'thinking' block
#[serde(default = "default_true")]
#[serde(default)]
pub request_thinking_signature: bool,
/// 请求整流:启用 thinking budget 整流器(默认开启)
///
/// 处理错误:budget_tokens + thinking 相关约束
#[serde(default = "default_true")]
pub request_thinking_budget: bool,
}
fn default_true() -> bool {
@@ -221,16 +216,6 @@ fn default_log_level() -> String {
"info".to_string()
}
impl Default for RectifierConfig {
fn default() -> Self {
Self {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
}
/// 日志配置
///
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
@@ -276,49 +261,32 @@ mod tests {
use super::*;
#[test]
fn test_rectifier_config_default_enabled() {
// 验证 RectifierConfig::default() 返回全开启状态
fn test_rectifier_config_default_disabled() {
// 验证 RectifierConfig::default() 返回全禁用状态
let config = RectifierConfig::default();
assert!(config.enabled, "整流器总开关默认应为 true");
assert!(!config.enabled, "整流器总开关默认应为 false");
assert!(
config.request_thinking_signature,
"thinking 签名整流器默认应为 true"
);
assert!(
config.request_thinking_budget,
"thinking budget 整流器默认应为 true"
!config.request_thinking_signature,
"thinking 签名整流器默认应为 false"
);
}
#[test]
fn test_rectifier_config_serde_default() {
// 验证反序列化缺字段时使用默认值 true
// 验证反序列化缺字段时使用默认值 false
let json = "{}";
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
assert!(config.request_thinking_budget);
assert!(!config.enabled);
assert!(!config.request_thinking_signature);
}
#[test]
fn test_rectifier_config_serde_explicit_true() {
// 验证显式设置 true 时正确反序列化
let json =
r#"{"enabled": true, "requestThinkingSignature": true, "requestThinkingBudget": true}"#;
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
assert!(config.request_thinking_budget);
}
#[test]
fn test_rectifier_config_serde_partial_fields() {
// 验证只设置部分字段时,缺失字段使用默认值 true
let json = r#"{"enabled": true, "requestThinkingSignature": false}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(!config.request_thinking_signature);
assert!(config.request_thinking_budget);
}
#[test]
+7 -32
View File
@@ -2,7 +2,6 @@ use crate::config::write_json_file;
use crate::database::OmoGlobalConfig;
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::provider::{CommonConfigEnabledByApp, ProviderMeta};
use crate::store::AppState;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -143,29 +142,6 @@ impl OmoService {
}
}
fn resolve_common_config_enabled(provider: &crate::provider::Provider) -> bool {
// Unified path: use provider meta (same mechanism as other apps).
if let Some(meta) = provider.meta.as_ref() {
if let Some(enabled) = meta
.common_config_enabled_by_app
.as_ref()
.and_then(|by_app| by_app.opencode)
{
return enabled;
}
if let Some(enabled) = meta.common_config_enabled {
return enabled;
}
}
// Backward compatibility: legacy OMO providers stored this flag in settings_config.
provider
.settings_config
.get("useCommonConfig")
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
pub fn delete_config_file() -> Result<(), AppError> {
let config_path = Self::config_path();
if config_path.exists() {
@@ -184,7 +160,11 @@ impl OmoService {
let agents = p.settings_config.get("agents").cloned();
let categories = p.settings_config.get("categories").cloned();
let other_fields = p.settings_config.get("otherFields").cloned();
let use_common_config = Self::resolve_common_config_enabled(p);
let use_common_config = p
.settings_config
.get("useCommonConfig")
.and_then(|v| v.as_bool())
.unwrap_or(true);
(agents, categories, other_fields, use_common_config)
});
@@ -257,6 +237,7 @@ impl OmoService {
if let Some(categories) = obj.get("categories") {
settings.insert("categories".to_string(), categories.clone());
}
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
let other = Self::extract_other_fields(&obj);
if !other.is_empty() {
@@ -282,13 +263,7 @@ impl OmoService {
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
notes: None,
meta: Some(ProviderMeta {
common_config_enabled_by_app: Some(CommonConfigEnabledByApp {
opencode: Some(true),
..Default::default()
}),
..Default::default()
}),
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
+13 -92
View File
@@ -1,14 +1,6 @@
//! Live configuration operations
//!
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
//!
//! ## Common Config Runtime Merge
//!
//! When writing to live files, this module performs runtime merge of:
//! - `customConfig` (provider's settings_config) - provider-specific settings
//! - `commonConfig` (from database settings table) - shared template settings
//!
//! The merge follows the rule: customConfig overrides commonConfig.
use std::collections::HashMap;
@@ -17,7 +9,6 @@ use serde_json::{json, Value};
use crate::app_config::AppType;
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
use crate::config_merge::merge_config_for_live;
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::mcp::McpService;
@@ -113,86 +104,24 @@ impl LiveSnapshot {
}
}
/// Write live configuration snapshot for a provider (raw, without common config merge)
///
/// This function writes the provider's settings_config directly to the live file.
/// Use `write_live_snapshot_with_merge` for runtime merge with common config.
/// Write live configuration snapshot for a provider
pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
write_live_snapshot_internal(app_type, provider, &provider.settings_config)
}
/// Write live configuration snapshot with common config runtime merge
///
/// This function performs runtime merge of:
/// - Provider's settings_config (custom config)
/// - Common config snippet from database (shared template)
///
/// The merge rule is: customConfig overrides commonConfig.
pub(crate) fn write_live_snapshot_with_merge(
state: &AppState,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
// Get common config snippet from database
let common_config_snippet = state.db.get_config_snippet(app_type.as_str())?;
// Use shared merge function (single source of truth)
let merge_result = merge_config_for_live(app_type, provider, common_config_snippet.as_deref());
// Log warning if any
if let Some(warning) = &merge_result.warning {
log::warn!(
"Common config merge warning for {:?} provider '{}': {}",
app_type,
provider.id,
warning
);
}
// Check if merge actually happened (config changed)
if merge_result.config != provider.settings_config {
log::debug!(
"Writing live config with common config merge for {:?} provider '{}'",
app_type,
provider.id
);
}
// Write the merged config to live file
let merged_provider = Provider {
settings_config: merge_result.config,
..provider.clone()
};
write_live_snapshot_internal(app_type, &merged_provider, &merged_provider.settings_config)
}
/// Internal function to write live configuration
fn write_live_snapshot_internal(
app_type: &AppType,
provider: &Provider,
config_to_write: &Value,
) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
let path = get_claude_settings_path();
let settings = sanitize_claude_settings_for_live(config_to_write);
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
write_json_file(&path, &settings)?;
}
AppType::Codex => {
let obj = config_to_write.as_object().ok_or_else(|| {
AppError::Config(
"CODEX_CONFIG_NOT_OBJECT: settings_config must be a JSON object".to_string(),
)
})?;
let auth = obj.get("auth").ok_or_else(|| {
AppError::Config(
"CODEX_CONFIG_MISSING_AUTH: settings_config missing 'auth' field".to_string(),
)
})?;
let obj = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
let auth = obj
.get("auth")
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config(
"CODEX_CONFIG_MISSING_CONFIG: settings_config missing 'config' field or not a string".to_string(),
)
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
})?;
let auth_path = get_codex_auth_path();
@@ -202,12 +131,7 @@ fn write_live_snapshot_internal(
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
// Create a temporary provider with the merged config
let temp_provider = Provider {
settings_config: config_to_write.clone(),
..provider.clone()
};
write_gemini_live(&temp_provider)?;
write_gemini_live(provider)?;
}
AppType::OpenCode => {
// OpenCode uses additive mode - write provider to config
@@ -215,7 +139,7 @@ fn write_live_snapshot_internal(
use crate::provider::OpenCodeProviderConfig;
// Defensive check: if settings_config is a full config structure, extract provider fragment
let config_to_write = if let Some(obj) = config_to_write.as_object() {
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
// Detect full config structure (has $schema or top-level provider field)
if obj.contains_key("$schema") || obj.contains_key("provider") {
log::warn!(
@@ -303,8 +227,6 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
///
/// This function uses `write_live_snapshot_with_merge` to perform runtime merge
/// with common config when enabled.
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Sync providers based on mode
@@ -322,8 +244,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
// Use write_live_snapshot_with_merge to support common config runtime merge
write_live_snapshot_with_merge(state, &app_type, provider)?;
write_live_snapshot(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
+21 -133
View File
@@ -13,9 +13,6 @@ use serde::Deserialize;
use serde_json::Value;
use crate::app_config::AppType;
use crate::config_merge::{
extract_json_difference, extract_toml_difference_str, is_common_config_enabled,
};
use crate::error::AppError;
use crate::provider::{Provider, UsageResult};
use crate::services::mcp::McpService;
@@ -30,7 +27,7 @@ pub use live::{
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::{write_live_snapshot, write_live_snapshot_with_merge};
pub(crate) use live::write_live_snapshot;
// Internal re-exports
use live::{remove_opencode_provider_from_live, write_gemini_live};
@@ -184,8 +181,7 @@ impl ProviderService {
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
// Use write_live_snapshot_with_merge to support common config runtime merge
write_live_snapshot_with_merge(state, &app_type, &provider)?;
write_live_snapshot(&app_type, &provider)?;
}
Ok(true)
@@ -245,8 +241,7 @@ impl ProviderService {
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
} else {
// Use write_live_snapshot_with_merge to support common config runtime merge
write_live_snapshot_with_merge(state, &app_type, &provider)?;
write_live_snapshot(&app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
@@ -459,33 +454,18 @@ impl ProviderService {
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
if let Some(current_id) = current_id {
if current_id != id {
// OpenCode uses additive mode - all providers coexist in the same file,
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
if !matches!(app_type, AppType::OpenCode) {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
// Check if common config is enabled for this provider
let common_enabled =
is_common_config_enabled(current_provider.meta.as_ref(), &app_type);
let config_to_save = if common_enabled {
// Extract custom config from live (remove common config parts)
Self::extract_custom_from_live(state, &app_type, &live_config)?
} else {
// Common config not enabled, use live config directly
live_config
};
current_provider.settings_config = config_to_save;
// Ignore backfill failure, don't affect switch flow
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
}
match (current_id, matches!(app_type, AppType::OpenCode)) {
(Some(current_id), false) if current_id != id => {
// Only backfill when switching to a different provider.
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow.
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
}
}
}
_ => {}
}
// OpenCode uses additive mode - skip setting is_current (no such concept)
@@ -497,9 +477,8 @@ impl ProviderService {
state.db.set_current_provider(app_type.as_str(), id)?;
}
// Sync to live (use write_live_snapshot_with_merge for common config runtime merge)
// Note: write_gemini_live handles security flag internally for Gemini
write_live_snapshot_with_merge(state, &app_type, provider)?;
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
@@ -552,88 +531,6 @@ impl ProviderService {
}
}
/// Extract custom config from live config (remove common config parts).
///
/// This is used during backfill to avoid polluting the provider's settings_config
/// with common config values that should remain in the common config snippet.
fn extract_custom_from_live(
state: &AppState,
app_type: &AppType,
live_config: &Value,
) -> Result<Value, AppError> {
// Get common config snippet from database
let common_snippet = state
.db
.get_config_snippet(app_type.as_str())?
.unwrap_or_default();
if common_snippet.trim().is_empty() {
// No common config, return live config as-is
return Ok(live_config.clone());
}
match app_type {
AppType::Claude => {
// Parse common config as JSON
let common_config: Value = serde_json::from_str(&common_snippet).map_err(|e| {
AppError::Config(format!("Failed to parse common config snippet: {e}"))
})?;
// Extract difference (custom = live - common)
let (custom_config, _) = extract_json_difference(live_config, &common_config);
Ok(custom_config)
}
AppType::Codex => {
// Codex: Extract TOML config field difference
let mut result = live_config.clone();
if let Some(config_str) = live_config.get("config").and_then(|v| v.as_str()) {
// Extract TOML difference for config field
// Returns (custom_toml, has_common_keys, error)
let (custom_toml, _, _) =
extract_toml_difference_str(config_str, &common_snippet);
if let Some(obj) = result.as_object_mut() {
obj.insert("config".to_string(), Value::String(custom_toml));
}
}
Ok(result)
}
AppType::Gemini => {
// Gemini: Extract env field difference
// Parse common config (supports ENV format and JSON format)
let common_env = crate::config_merge::parse_gemini_common_snippet(&common_snippet);
if common_env.is_empty() {
return Ok(live_config.clone());
}
let mut result = live_config.clone();
if let Some(live_env) = live_config.get("env").and_then(|v| v.as_object()) {
// Extract difference: custom = live_env - common_env
let mut custom_env = serde_json::Map::new();
for (key, value) in live_env {
if common_env.get(key) != Some(value) {
// Key doesn't exist in common or value is different
custom_env.insert(key.clone(), value.clone());
}
}
if let Some(obj) = result.as_object_mut() {
obj.insert("env".to_string(), Value::Object(custom_env));
}
}
Ok(result)
}
AppType::OpenCode => {
// OpenCode doesn't support common config
Ok(live_config.clone())
}
}
}
/// Extract common config for Claude (JSON format)
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
let mut config = settings.clone();
@@ -732,19 +629,15 @@ impl ProviderService {
Ok(cleaned.trim().to_string())
}
/// Extract common config for Gemini (ENV format)
/// Extract common config for Gemini (JSON format)
///
/// Extracts `.env` values while excluding provider-specific credentials:
/// - GOOGLE_GEMINI_BASE_URL
/// - GEMINI_API_KEY
///
/// Returns ENV format (KEY=VALUE per line) instead of JSON.
/// Values containing newlines/carriage returns are skipped to prevent
/// ENV format injection/truncation.
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
let env = settings.get("env").and_then(|v| v.as_object());
let mut lines: Vec<String> = Vec::new();
let mut snippet = serde_json::Map::new();
if let Some(env) = env {
for (key, value) in env {
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
@@ -755,22 +648,17 @@ impl ProviderService {
};
let trimmed = v.trim();
if !trimmed.is_empty() {
// Skip values containing newlines to prevent ENV format injection
if trimmed.contains('\n') || trimmed.contains('\r') {
continue;
}
lines.push(format!("{key}={trimmed}"));
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
}
}
}
if lines.is_empty() {
return Ok(String::new());
if snippet.is_empty() {
return Ok("{}".to_string());
}
// Sort for consistent output
lines.sort();
Ok(lines.join("\n"))
serde_json::to_string_pretty(&Value::Object(snippet))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for OpenCode (JSON format)
+8 -53
View File
@@ -4,7 +4,6 @@
use crate::app_config::AppType;
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::config_merge::merge_config_for_live;
use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::server::ProxyServer;
@@ -1233,27 +1232,7 @@ impl ProxyService {
return Ok(false);
};
// Use shared merge function (single source of truth)
let common_config_snippet = self.db.get_config_snippet(app_type.as_str()).ok().flatten();
let merge_result =
merge_config_for_live(app_type, provider, common_config_snippet.as_deref());
// Log warning if any
if let Some(warning) = &merge_result.warning {
log::warn!(
"Common config merge warning for {:?} provider '{}': {}",
app_type,
provider.id,
warning
);
}
// Write merged config to live file
let merged_provider = Provider {
settings_config: merge_result.config,
..provider.clone()
};
write_live_snapshot(app_type, &merged_provider)
write_live_snapshot(app_type, provider)
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
Ok(true)
@@ -1506,50 +1485,26 @@ impl ProxyService {
///
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,
/// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。
///
/// **重要**: 新架构下 settings_config 存储的是自定义配置(custom diff),
/// 备份时需要先与 common config 合并,生成完整的 finalConfig。
pub async fn update_live_backup_from_provider(
&self,
app_type: &str,
provider: &Provider,
) -> Result<(), String> {
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
// Get common config snippet for merge
let common_snippet = self.db.get_config_snippet(app_type).ok().flatten();
// Merge custom config with common config to get final config
let merge_result =
merge_config_for_live(&app_type_enum, provider, common_snippet.as_deref());
// Log warning if any
if let Some(warning) = &merge_result.warning {
log::warn!(
"Common config merge warning for {} provider '{}': {}",
app_type,
provider.id,
warning
);
}
let final_config = merge_result.config;
let backup_json = match app_type {
"claude" => {
// Claude: 使用合并后的 final config 作为备份
serde_json::to_string(&final_config)
// Claude: settings_config 直接作为备份
serde_json::to_string(&provider.settings_config)
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
}
"codex" => {
// Codex: 使用合并后的 final config
serde_json::to_string(&final_config)
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
serde_json::to_string(&provider.settings_config)
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
}
"gemini" => {
// Gemini: 只提取 env 字段(与原始备份格式一致)
let env_backup = if let Some(env) = final_config.get("env") {
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
let env_backup = if let Some(env) = provider.settings_config.get("env") {
json!({ "env": env })
} else {
json!({ "env": {} })
@@ -1565,7 +1520,7 @@ impl ProxyService {
.await
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
log::info!("已更新 {app_type} Live 备份(热切换,含 common config 合并");
log::info!("已更新 {app_type} Live 备份(热切换)");
Ok(())
}
+3
View File
@@ -53,12 +53,15 @@ export function DeepLinkImportDialog() {
const unlistenImport = listen<DeepLinkImportRequest>(
"deeplink-import",
async (event) => {
console.log("Deep link import event received:", event.payload);
// If config is present, merge it to get the complete configuration
if (event.payload.config || event.payload.configUrl) {
try {
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
event.payload,
);
console.log("Config merged successfully:", mergedRequest);
setRequest(mergedRequest);
} catch (error) {
console.error("Failed to merge config:", error);
+5 -51
View File
@@ -22,10 +22,6 @@ interface JsonEditorProps {
language?: "json" | "javascript";
height?: string | number;
showMinimap?: boolean; // 添加此属性以防未来使用
/** 只读模式 */
readOnly?: boolean;
/** 自动高度模式:根据内容自动调整高度,rows 作为最小行数 */
autoHeight?: boolean;
}
const JsonEditor: React.FC<JsonEditorProps> = ({
@@ -37,8 +33,6 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
showValidation = true,
language = "json",
height,
readOnly = false,
autoHeight = false,
}) => {
const { t } = useTranslation();
const editorRef = useRef<HTMLDivElement>(null);
@@ -88,14 +82,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
if (!editorRef.current) return;
// 创建编辑器扩展
const lineHeight = 18;
const minHeightPx = height ? undefined : Math.max(1, rows) * lineHeight;
// 自动高度模式:计算内容行数
const contentLines = value ? value.split("\n").length : 1;
const autoHeightPx = autoHeight
? Math.max(rows, contentLines) * lineHeight + 10 // +10 for padding
: undefined;
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
// 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题
const baseTheme = EditorView.baseTheme({
@@ -136,17 +123,9 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
? `${height}px`
: height
: undefined;
// 确定最终高度:优先级 height > autoHeight > minHeight
const finalHeight = heightValue
? heightValue
: autoHeightPx
? `${autoHeightPx}px`
: undefined;
const sizingTheme = EditorView.theme({
"&": finalHeight
? { height: finalHeight }
"&": heightValue
? { height: heightValue }
: { minHeight: `${minHeightPx}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": {
@@ -171,22 +150,6 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
}),
];
// 如果是只读模式,添加只读扩展
if (readOnly) {
extensions.push(EditorState.readOnly.of(true));
extensions.push(
EditorView.theme({
".cm-editor": {
opacity: "0.8",
cursor: "default",
},
".cm-content": {
cursor: "default",
},
}),
);
}
// 如果启用深色模式,添加深色主题
if (darkMode) {
extensions.push(oneDark);
@@ -245,16 +208,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
view.destroy();
viewRef.current = null;
};
}, [
darkMode,
rows,
height,
language,
jsonLinter,
readOnly,
autoHeight,
autoHeight ? value.split("\n").length : 0,
]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建;autoHeight 模式下根据行数变化重建
}, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
// 当 value 从外部改变时更新编辑器内容
useEffect(() => {
@@ -307,7 +261,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
className={isFullHeight ? "flex-1 min-h-0" : ""}
/>
{language === "json" && !readOnly && (
{language === "json" && (
<button
type="button"
onClick={handleFormat}
+3 -113
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
@@ -9,13 +8,7 @@ import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api";
import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import {
parseGeminiCommonConfigSnippet,
mapGeminiWarningToI18n,
} from "@/utils/providerConfigUtils";
import { providersApi, vscodeApi, type AppId } from "@/lib/api";
interface EditProviderDialogProps {
open: boolean;
@@ -88,103 +81,7 @@ export function EditProviderDialog({
appId,
)) as Record<string, unknown>;
if (!cancelled && live && typeof live === "object") {
// 检查是否启用了通用配置
const metaByApp = provider.meta?.commonConfigEnabledByApp;
const commonConfigEnabled =
metaByApp?.[appId] ??
provider.meta?.commonConfigEnabled ??
false;
if (commonConfigEnabled) {
// 从 live 配置中提取自定义部分(去除通用配置)
try {
const commonSnippet =
await configApi.getCommonConfigSnippet(appId);
if (commonSnippet && commonSnippet.trim()) {
if (appId === "codex") {
// Codex: 处理 TOML 格式的 config 字段
const liveConfig =
(live as { auth?: unknown; config?: string }).config ??
"";
const { customToml, error } = extractTomlDifference(
liveConfig,
commonSnippet.trim(),
);
if (!error) {
setLiveSettings({
...live,
config: customToml,
});
} else {
setLiveSettings(live);
}
} else if (appId === "gemini") {
// Gemini: common config supports three formats:
// - ENV format: KEY=VALUE lines
// - Flat JSON: {"KEY": "VALUE", ...}
// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
const liveEnv =
(live as { env?: Record<string, string> }).env ?? {};
// Use shared parser with validation
const parseResult = parseGeminiCommonConfigSnippet(
commonSnippet,
{ strictForbiddenKeys: false },
);
if (parseResult.error) {
console.warn(
"[EditProviderDialog] Gemini common config parse error:",
parseResult.error,
);
setLiveSettings(live);
} else {
// Show warning toast if keys were filtered
if (parseResult.warning) {
toast.warning(
mapGeminiWarningToI18n(parseResult.warning, t),
);
}
if (
isPlainObject(liveEnv) &&
Object.keys(parseResult.env).length > 0
) {
const { customConfig } = extractDifference(
liveEnv,
parseResult.env,
);
setLiveSettings({
...live,
env: customConfig,
});
} else {
setLiveSettings(live);
}
}
} else {
// Claude: 处理 JSON 格式
const commonConfig = JSON.parse(commonSnippet.trim());
if (isPlainObject(live) && isPlainObject(commonConfig)) {
const { customConfig } = extractDifference(
live,
commonConfig,
);
setLiveSettings(customConfig);
} else {
setLiveSettings(live);
}
}
} else {
setLiveSettings(live);
}
} catch {
// 提取失败时使用原始 live 配置
setLiveSettings(live);
}
} else {
setLiveSettings(live);
}
setLiveSettings(live);
setHasLoadedLive(true);
}
} catch {
@@ -208,14 +105,7 @@ export function EditProviderDialog({
return () => {
cancelled = true;
};
}, [
open,
provider?.id,
provider?.meta,
appId,
hasLoadedLive,
isProxyTakeover,
]); // 添加 provider?.meta 依赖
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
@@ -30,9 +30,6 @@ interface CodexConfigEditorProps {
onExtract?: () => void;
isExtracting?: boolean;
/** 最终合并后的配置(只读预览) */
finalConfig?: string;
}
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
@@ -50,7 +47,6 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
configError,
onExtract,
isExtracting,
finalConfig,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
@@ -80,7 +76,6 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
configError={configError}
finalConfig={finalConfig}
/>
{/* Common Config Modal */}
@@ -1,9 +1,6 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import { Label } from "@/components/ui/label";
import { useDarkMode } from "@/hooks/useDarkMode";
interface CodexAuthSectionProps {
value: string;
@@ -22,7 +19,22 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
error,
}) => {
const { t } = useTranslation();
const isDarkMode = useDarkMode();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleChange = (newValue: string) => {
onChange(newValue);
@@ -45,8 +57,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
onChange={handleChange}
placeholder={t("codexConfig.authJsonPlaceholder")}
darkMode={isDarkMode}
rows={3}
autoHeight={true}
rows={6}
showValidation={true}
language="json"
/>
@@ -72,8 +83,6 @@ interface CodexConfigSectionProps {
onEditCommonConfig: () => void;
commonConfigError?: string;
configError?: string;
/** 最终合并后的配置(只读预览) */
finalConfig?: string;
}
/**
@@ -87,18 +96,24 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
onEditCommonConfig,
commonConfigError,
configError,
finalConfig,
}) => {
const { t } = useTranslation();
const isDarkMode = useDarkMode();
const [showPreview, setShowPreview] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// 当启用通用配置时,自动显示预览
useEffect(() => {
if (useCommonConfig && finalConfig) {
setShowPreview(true);
}
}, [useCommonConfig, finalConfig]);
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<div className="space-y-2">
@@ -121,32 +136,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
</label>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{useCommonConfig && finalConfig && (
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="inline-flex items-center gap-1 text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{showPreview ? (
<>
<EyeOff className="w-3 h-3" />
{t("codexConfig.hidePreview", {
defaultValue: "隐藏合并预览",
})}
</>
) : (
<>
<Eye className="w-3 h-3" />
{t("codexConfig.showPreview", {
defaultValue: "显示合并预览",
})}
</>
)}
</button>
)}
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditCommonConfig}
@@ -162,58 +152,15 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
</p>
)}
{/* 自定义配置编辑器 */}
<div className="space-y-1">
{useCommonConfig && showPreview && (
<Label className="text-xs text-muted-foreground">
{t("codexConfig.customConfig", {
defaultValue: "自定义配置(覆盖通用配置)",
})}
</Label>
)}
<JsonEditor
value={value}
onChange={onChange}
placeholder=""
darkMode={isDarkMode}
rows={useCommonConfig && showPreview ? 3 : 8}
autoHeight={useCommonConfig && showPreview}
showValidation={false}
language="javascript"
/>
</div>
{/* 合并预览(只读)- 放在自定义配置下面 */}
{useCommonConfig && showPreview && finalConfig && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{t("codexConfig.mergedPreview", {
defaultValue: "合并预览(只读)",
})}
</Label>
<span className="text-xs text-green-500 dark:text-green-400">
{t("codexConfig.mergedPreviewHint", {
defaultValue: "通用配置 + 自定义配置 = 最终配置",
})}
</span>
</div>
<div className="relative">
<JsonEditor
value={finalConfig}
onChange={() => {}} // 只读
darkMode={isDarkMode}
rows={6}
showValidation={false}
language="javascript"
readOnly={true}
/>
<div className="absolute top-2 right-2 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 text-xs rounded">
{t("common.readonly", { defaultValue: "只读" })}
</div>
</div>
</div>
)}
<JsonEditor
value={value}
onChange={onChange}
placeholder=""
darkMode={isDarkMode}
rows={8}
showValidation={false}
language="javascript"
/>
{configError && (
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
@@ -3,9 +3,8 @@ import { useEffect, useState } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Save, Download, Loader2, Eye, EyeOff } from "lucide-react";
import { Save, Download, Loader2 } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import { useDarkMode } from "@/hooks/useDarkMode";
interface CommonConfigEditorProps {
value: string;
@@ -20,8 +19,6 @@ interface CommonConfigEditorProps {
onModalClose: () => void;
onExtract?: () => void;
isExtracting?: boolean;
/** 最终合并后的配置(只读预览) */
finalConfig?: string;
}
export function CommonConfigEditor({
@@ -37,18 +34,24 @@ export function CommonConfigEditor({
onModalClose,
onExtract,
isExtracting,
finalConfig,
}: CommonConfigEditorProps) {
const { t } = useTranslation();
const isDarkMode = useDarkMode();
const [showPreview, setShowPreview] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// 当启用通用配置时,自动显示预览
useEffect(() => {
if (useCommonConfig && finalConfig) {
setShowPreview(true);
}
}, [useCommonConfig, finalConfig]);
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<>
@@ -72,32 +75,7 @@ export function CommonConfigEditor({
</label>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{useCommonConfig && finalConfig && (
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="inline-flex items-center gap-1 text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{showPreview ? (
<>
<EyeOff className="w-3 h-3" />
{t("claudeConfig.hidePreview", {
defaultValue: "隐藏合并预览",
})}
</>
) : (
<>
<Eye className="w-3 h-3" />
{t("claudeConfig.showPreview", {
defaultValue: "显示合并预览",
})}
</>
)}
</button>
)}
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditClick}
@@ -113,64 +91,20 @@ export function CommonConfigEditor({
{commonConfigError}
</p>
)}
{/* 自定义配置编辑器 */}
<div className="space-y-1">
{useCommonConfig && showPreview && (
<Label className="text-xs text-muted-foreground">
{t("claudeConfig.customConfig", {
defaultValue: "自定义配置(覆盖通用配置)",
})}
</Label>
)}
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
}
}`}
darkMode={isDarkMode}
rows={useCommonConfig && showPreview ? 3 : 14}
autoHeight={useCommonConfig && showPreview}
showValidation={true}
language="json"
/>
</div>
{/* 合并预览(只读)- 放在自定义配置下面 */}
{useCommonConfig && showPreview && finalConfig && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{t("claudeConfig.mergedPreview", {
defaultValue: "合并预览(只读)",
})}
</Label>
<span className="text-xs text-green-500 dark:text-green-400">
{t("claudeConfig.mergedPreviewHint", {
defaultValue: "通用配置 + 自定义配置 = 最终配置",
})}
</span>
</div>
<div className="relative">
<JsonEditor
value={finalConfig}
onChange={() => {}} // 只读
darkMode={isDarkMode}
rows={8}
showValidation={false}
language="json"
readOnly={true}
/>
<div className="absolute top-2 right-2 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 text-xs rounded">
{t("common.readonly", { defaultValue: "只读" })}
</div>
</div>
</div>
)}
darkMode={isDarkMode}
rows={14}
showValidation={true}
language="json"
/>
</div>
<FullScreenPanel
@@ -18,7 +18,6 @@ interface GeminiCommonConfigModalProps {
/**
* GeminiCommonConfigModal - Common Gemini configuration editor modal
* Allows editing of common env snippet shared across Gemini providers
* Uses ENV format (KEY=VALUE) instead of JSON
*/
export const GeminiCommonConfigModal: React.FC<
GeminiCommonConfigModalProps
@@ -89,14 +88,13 @@ export const GeminiCommonConfigModal: React.FC<
<JsonEditor
value={value}
onChange={onChange}
placeholder={`# Gemini 通用配置
# 格式: KEY=VALUE
GEMINI_MODEL=gemini-2.5-pro`}
placeholder={`{
"GEMINI_MODEL": "gemini-3-pro-preview"
}`}
darkMode={isDarkMode}
rows={16}
showValidation={false}
language="javascript"
showValidation={true}
language="json"
/>
{error && (
@@ -17,8 +17,6 @@ interface GeminiConfigEditorProps {
configError: string;
onExtract?: () => void;
isExtracting?: boolean;
/** 最终合并后的 env 配置(只读预览) */
finalEnv?: string;
}
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
@@ -36,7 +34,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
configError,
onExtract,
isExtracting,
finalEnv,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
@@ -59,7 +56,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
onCommonConfigToggle={onCommonConfigToggle}
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
finalEnv={finalEnv}
/>
{/* Config JSON Section */}
@@ -1,9 +1,6 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import { Label } from "@/components/ui/label";
import { useDarkMode } from "@/hooks/useDarkMode";
interface GeminiEnvSectionProps {
value: string;
@@ -14,8 +11,6 @@ interface GeminiEnvSectionProps {
onCommonConfigToggle: (checked: boolean) => void;
onEditCommonConfig: () => void;
commonConfigError?: string;
/** 最终合并后的 env 配置(只读预览) */
finalEnv?: string;
}
/**
@@ -30,18 +25,24 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
onCommonConfigToggle,
onEditCommonConfig,
commonConfigError,
finalEnv,
}) => {
const { t } = useTranslation();
const isDarkMode = useDarkMode();
const [showPreview, setShowPreview] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// 当启用通用配置时,自动显示预览
useEffect(() => {
if (useCommonConfig && finalEnv) {
setShowPreview(true);
}
}, [useCommonConfig, finalEnv]);
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleChange = (newValue: string) => {
onChange(newValue);
@@ -73,32 +74,7 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
</label>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{useCommonConfig && finalEnv && (
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="inline-flex items-center gap-1 text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{showPreview ? (
<>
<EyeOff className="w-3 h-3" />
{t("geminiConfig.hidePreview", {
defaultValue: "隐藏合并预览",
})}
</>
) : (
<>
<Eye className="w-3 h-3" />
{t("geminiConfig.showPreview", {
defaultValue: "显示合并预览",
})}
</>
)}
</button>
)}
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditCommonConfig}
@@ -116,60 +92,17 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
</p>
)}
{/* 自定义配置编辑器 */}
<div className="space-y-1">
{useCommonConfig && showPreview && (
<Label className="text-xs text-muted-foreground">
{t("geminiConfig.customConfig", {
defaultValue: "自定义配置(覆盖通用配置)",
})}
</Label>
)}
<JsonEditor
value={value}
onChange={handleChange}
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
<JsonEditor
value={value}
onChange={handleChange}
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
GEMINI_API_KEY=sk-your-api-key-here
GEMINI_MODEL=gemini-3-pro-preview`}
darkMode={isDarkMode}
rows={useCommonConfig && showPreview ? 3 : 6}
autoHeight={useCommonConfig && showPreview}
showValidation={false}
language="javascript"
/>
</div>
{/* 合并预览(只读)- 放在自定义配置下面 */}
{useCommonConfig && showPreview && finalEnv && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{t("geminiConfig.mergedPreview", {
defaultValue: "合并预览(只读)",
})}
</Label>
<span className="text-xs text-green-500 dark:text-green-400">
{t("geminiConfig.mergedPreviewHint", {
defaultValue: "通用配置 + 自定义配置 = 最终配置",
})}
</span>
</div>
<div className="relative">
<JsonEditor
value={finalEnv}
onChange={() => {}} // 只读
darkMode={isDarkMode}
rows={4}
showValidation={false}
language="javascript"
readOnly={true}
/>
<div className="absolute top-2 right-2 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 text-xs rounded">
{t("common.readonly", { defaultValue: "只读" })}
</div>
</div>
</div>
)}
darkMode={isDarkMode}
rows={6}
showValidation={false}
language="javascript"
/>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
@@ -201,7 +134,22 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
configError,
}) => {
const { t } = useTranslation();
const isDarkMode = useDarkMode();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<div className="space-y-2">
+54 -311
View File
@@ -7,13 +7,12 @@ import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import { providersApi, configApi, type AppId } from "@/lib/api";
import { providersApi, type AppId } from "@/lib/api";
import type {
ProviderCategory,
ProviderMeta,
ProviderTestConfig,
ProviderProxyConfig,
CommonConfigEnabledByApp,
ClaudeApiFormat,
OpenCodeModel,
OpenCodeProviderConfig,
@@ -39,12 +38,6 @@ import { OpenCodeFormFields } from "./OpenCodeFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import {
parseGeminiCommonConfigSnippet,
mapGeminiWarningToI18n,
} from "@/utils/providerConfigUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor";
@@ -59,6 +52,7 @@ import { GeminiFormFields } from "./GeminiFormFields";
import { OmoFormFields } from "./OmoFormFields";
import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields";
import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor";
import * as configApi from "@/lib/api/config";
import type { OmoGlobalConfig } from "@/types/omo";
import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo";
import {
@@ -73,49 +67,16 @@ import {
useCodexConfigState,
useApiKeyLink,
useTemplateValues,
useCommonConfigSnippet,
useCodexCommonConfig,
useSpeedTestEndpoints,
useCodexTomlValidation,
useGeminiConfigState,
useGeminiCommonConfig,
} from "./hooks";
import { useCommonConfigBase } from "@/hooks/useCommonConfigBase";
import {
claudeAdapter,
createGeminiAdapter,
} from "@/hooks/commonConfigAdapters";
import { useProvidersQuery } from "@/lib/query/queries";
import { useOmoGlobalConfig } from "@/lib/query/omo";
/**
* Parse Gemini common config snippet for difference extraction.
* Uses shared parser with non-strict forbidden keys (filter instead of reject).
*
* Supports three formats:
* - ENV format: KEY=VALUE lines (one per line)
* - Flat JSON: {"KEY": "VALUE", ...}
* - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
*
* Returns { env, warning } - caller should display warning via toast if present.
*/
function parseGeminiCommonConfig(snippet: string): {
env: Record<string, string>;
warning?: string;
} {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: false, // Don't fail, just filter
});
if (result.error) {
console.warn(
"[ProviderForm] Gemini common config parse error:",
result.error,
);
return { env: {} };
}
return { env: result.env, warning: result.warning };
}
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
const GEMINI_DEFAULT_CONFIG = JSON.stringify(
@@ -446,12 +407,8 @@ export function ProviderForm({
resetCodexConfig,
} = useCodexConfigState({ initialData });
// 使用 Codex TOML 校验 hook (仅 Codex 模式)
const {
configError: codexConfigError,
debouncedValidate,
validateToml,
} = useCodexTomlValidation();
const { configError: codexConfigError, debouncedValidate } =
useCodexTomlValidation();
const handleCodexConfigChange = useCallback(
(value: string) => {
@@ -527,18 +484,6 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
// 使用通用配置片段 hook (仅 Claude 模式)
// 直接使用 useCommonConfigBase + claudeAdapter
const claudeCommonConfig = useCommonConfigBase({
adapter: claudeAdapter,
inputValue: form.getValues("settingsConfig"),
onInputChange: (config) => form.setValue("settingsConfig", config),
initialData: appId === "claude" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "claude",
});
// 解构 Claude 通用配置
const {
useCommonConfig,
commonConfigSnippet,
@@ -547,11 +492,13 @@ export function ProviderForm({
handleCommonConfigSnippetChange,
isExtracting: isClaudeExtracting,
handleExtract: handleClaudeExtract,
isLoading: isClaudeCommonConfigLoading,
finalValue: claudeFinalConfig,
getPendingCommonConfigSnippet: getPendingClaudeCommonConfigSnippet,
markCommonConfigSaved: markClaudeCommonConfigSaved,
} = claudeCommonConfig;
} = useCommonConfigSnippet({
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
initialData: appId === "claude" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "claude",
});
const {
useCommonConfig: useCodexCommonConfigFlag,
@@ -561,16 +508,11 @@ export function ProviderForm({
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
isExtracting: isCodexExtracting,
handleExtract: handleCodexExtract,
isLoading: isCodexCommonConfigLoading,
finalConfig: codexFinalConfig,
getPendingCommonConfigSnippet: getPendingCodexCommonConfigSnippet,
markCommonConfigSaved: markCodexCommonConfigSaved,
} = useCodexCommonConfig({
codexConfig,
onConfigChange: handleCodexConfigChange,
initialData: appId === "codex" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
currentProviderId: providerId,
});
const {
@@ -593,76 +535,52 @@ export function ProviderForm({
initialData: appId === "gemini" ? initialData : undefined,
});
const updateGeminiEnvField = useCallback(
(
key: "GEMINI_API_KEY" | "GOOGLE_GEMINI_BASE_URL" | "GEMINI_MODEL",
value: string,
) => {
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}") as {
env?: Record<string, unknown>;
};
if (!config.env || typeof config.env !== "object") {
config.env = {};
}
config.env[key] = value;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {}
},
[form],
);
const handleGeminiApiKeyChange = useCallback(
(key: string) => {
originalHandleGeminiApiKeyChange(key);
// 同步更新 settingsConfig
let config: { env?: Record<string, unknown> };
try {
config = JSON.parse(form.getValues("settingsConfig") || "{}");
} catch {
// 解析失败时初始化为有效结构
config = { env: {} };
}
if (!config.env) config.env = {};
config.env.GEMINI_API_KEY = key.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
updateGeminiEnvField("GEMINI_API_KEY", key.trim());
},
[originalHandleGeminiApiKeyChange, form],
[originalHandleGeminiApiKeyChange, updateGeminiEnvField],
);
const handleGeminiBaseUrlChange = useCallback(
(url: string) => {
originalHandleGeminiBaseUrlChange(url);
// 同步更新 settingsConfig
let config: { env?: Record<string, unknown> };
try {
config = JSON.parse(form.getValues("settingsConfig") || "{}");
} catch {
// 解析失败时初始化为有效结构
config = { env: {} };
}
if (!config.env) config.env = {};
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
updateGeminiEnvField(
"GOOGLE_GEMINI_BASE_URL",
url.trim().replace(/\/+$/, ""),
);
},
[originalHandleGeminiBaseUrlChange, form],
[originalHandleGeminiBaseUrlChange, updateGeminiEnvField],
);
const handleGeminiModelChange = useCallback(
(model: string) => {
originalHandleGeminiModelChange(model);
// 同步更新 settingsConfig
let config: { env?: Record<string, unknown> };
try {
config = JSON.parse(form.getValues("settingsConfig") || "{}");
} catch {
// 解析失败时初始化为有效结构
config = { env: {} };
}
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
updateGeminiEnvField("GEMINI_MODEL", model.trim());
},
[originalHandleGeminiModelChange, form],
[originalHandleGeminiModelChange, updateGeminiEnvField],
);
// 使用 Gemini 通用配置 hook (仅 Gemini 模式)
// 直接使用 useCommonConfigBase + createGeminiAdapter
const geminiAdapter = useMemo(
() => createGeminiAdapter({ envStringToObj, envObjToString }),
[envStringToObj, envObjToString],
);
const geminiCommonConfig = useCommonConfigBase({
adapter: geminiAdapter,
inputValue: geminiEnv,
onInputChange: handleGeminiEnvChange,
initialData: appId === "gemini" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "gemini",
});
// 解构 Gemini 通用配置
const {
useCommonConfig: useGeminiCommonConfigFlag,
commonConfigSnippet: geminiCommonConfigSnippet,
@@ -671,11 +589,14 @@ export function ProviderForm({
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
isExtracting: isGeminiExtracting,
handleExtract: handleGeminiExtract,
isLoading: isGeminiCommonConfigLoading,
finalValue: geminiFinalEnv,
getPendingCommonConfigSnippet: getPendingGeminiCommonConfigSnippet,
markCommonConfigSaved: markGeminiCommonConfigSaved,
} = geminiCommonConfig;
} = useGeminiCommonConfig({
envValue: geminiEnv,
onEnvChange: handleGeminiEnvChange,
envStringToObj,
envObjToString,
initialData: appId === "gemini" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
const existingOpencodeKeys = useMemo(() => {
@@ -983,13 +904,6 @@ export function ProviderForm({
const [isOmoConfigModalOpen, setIsOmoConfigModalOpen] = useState(false);
const [useOmoCommonConfig, setUseOmoCommonConfig] = useState(() => {
const metaByApp = initialData?.meta?.commonConfigEnabledByApp;
const fromMeta =
metaByApp?.opencode ?? initialData?.meta?.commonConfigEnabled;
if (typeof fromMeta === "boolean") {
return fromMeta;
}
// Backward compatibility for old OMO data.
const raw = initialOmoSettings?.useCommonConfig;
return typeof raw === "boolean" ? raw : true;
});
@@ -1073,21 +987,6 @@ export function ProviderForm({
setUseOmoCommonConfig(useCommonConfig);
}, []);
const supportsCommonConfig =
appId === "claude" ||
appId === "codex" ||
appId === "gemini" ||
isOmoCategory;
const commonConfigEnabled = supportsCommonConfig
? appId === "claude"
? useCommonConfig
: appId === "codex"
? useCodexCommonConfigFlag
: appId === "gemini"
? useGeminiCommonConfigFlag
: useOmoCommonConfig
: undefined;
const updateOpencodeSettings = useCallback(
(updater: (config: Record<string, any>) => void) => {
try {
@@ -1172,63 +1071,7 @@ export function ProviderForm({
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
const handleSubmit = async (values: ProviderFormData) => {
// 检查通用配置是否仍在加载中
const isCommonConfigLoading =
(appId === "claude" && isClaudeCommonConfigLoading) ||
(appId === "codex" && isCodexCommonConfigLoading) ||
(appId === "gemini" && isGeminiCommonConfigLoading);
if (isCommonConfigLoading) {
toast.error(t("providerForm.commonConfigLoading"));
return;
}
const commonConfigErrorMessage =
appId === "claude" && useCommonConfig && commonConfigError
? commonConfigError
: appId === "codex" &&
useCodexCommonConfigFlag &&
codexCommonConfigError
? codexCommonConfigError
: appId === "gemini" &&
useGeminiCommonConfigFlag &&
geminiCommonConfigError
? geminiCommonConfigError
: "";
if (commonConfigErrorMessage) {
toast.error(commonConfigErrorMessage);
return;
}
if (appId === "codex") {
if (codexAuthError) {
toast.error(codexAuthError);
return;
}
const isCodexConfigValid = validateToml(codexConfig);
if (!isCodexConfigValid) {
toast.error(
codexConfigError ||
t("mcp.error.tomlInvalid", {
defaultValue: "TOML 格式错误,请检查",
}),
);
return;
}
}
if (appId === "gemini") {
if (envError) {
toast.error(envError);
return;
}
if (geminiConfigError) {
toast.error(geminiConfigError);
return;
}
}
// 验证模板变量(仅 Claude 模式)
const handleSubmit = (values: ProviderFormData) => {
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
if (!validation.isValid && validation.missingField) {
@@ -1326,56 +1169,14 @@ export function ProviderForm({
}
}
// 保存待保存的通用配置(延迟保存模式:在表单提交时统一保存)
try {
if (appId === "claude") {
const pendingSnippet = getPendingClaudeCommonConfigSnippet();
if (pendingSnippet !== null) {
await configApi.setCommonConfigSnippet("claude", pendingSnippet);
markClaudeCommonConfigSaved();
}
} else if (appId === "codex") {
const pendingSnippet = getPendingCodexCommonConfigSnippet();
if (pendingSnippet !== null) {
await configApi.setCommonConfigSnippet("codex", pendingSnippet);
markCodexCommonConfigSaved();
}
} else if (appId === "gemini") {
const pendingSnippet = getPendingGeminiCommonConfigSnippet();
if (pendingSnippet !== null) {
await configApi.setCommonConfigSnippet("gemini", pendingSnippet);
markGeminiCommonConfigSaved();
}
}
} catch (error) {
console.error("保存通用配置失败:", error);
toast.error(
t("providerForm.saveCommonConfigFailed", {
defaultValue: "保存通用配置失败",
}),
);
return;
}
let settingsConfig: string;
if (appId === "codex") {
try {
const authJson = JSON.parse(codexAuth);
// 如果启用了通用配置,只保存与通用配置不同的部分(自定义配置)
let configToSave = codexConfig ?? "";
if (useCodexCommonConfigFlag && codexCommonConfigSnippet.trim()) {
const { customToml, error } = extractTomlDifference(
codexConfig ?? "",
codexCommonConfigSnippet.trim(),
);
if (!error) {
configToSave = customToml;
}
}
const configObj = {
auth: authJson,
config: configToSave,
config: codexConfig ?? "",
};
settingsConfig = JSON.stringify(configObj);
} catch (err) {
@@ -1383,29 +1184,8 @@ export function ProviderForm({
}
} else if (appId === "gemini") {
try {
let envObj = envStringToObj(geminiEnv);
const envObj = envStringToObj(geminiEnv);
const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {};
// 如果启用了通用配置,只保存与通用配置不同的部分(自定义配置)
if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) {
// Parse common config snippet (supports ENV/Flat JSON/Wrapped JSON formats)
// Uses parseGeminiCommonConfigSnippet for unified parsing
const { env: commonEnvObj, warning } = parseGeminiCommonConfig(
geminiCommonConfigSnippet.trim(),
);
// Show warning toast if keys were filtered
if (warning) {
toast.warning(mapGeminiWarningToI18n(warning, t));
}
if (isPlainObject(envObj) && isPlainObject(commonEnvObj)) {
const { customConfig } = extractDifference(envObj, commonEnvObj);
// Convert to string record with type guard to avoid type assertion issues
const stringEnvObj: Record<string, string> = {};
for (const [key, value] of Object.entries(customConfig)) {
stringEnvObj[key] = String(value);
}
envObj = stringEnvObj;
}
}
const combined = {
env: envObj,
config: configObj,
@@ -1416,6 +1196,7 @@ export function ProviderForm({
}
} else if (appId === "opencode" && category === "omo") {
const omoConfig: Record<string, unknown> = {};
omoConfig.useCommonConfig = useOmoCommonConfig;
if (Object.keys(omoAgents).length > 0) {
omoConfig.agents = omoAgents;
}
@@ -1448,28 +1229,7 @@ export function ProviderForm({
}
settingsConfig = JSON.stringify(omoConfig);
} else {
// Claude: 使用表单配置
// 如果启用了通用配置,只保存与通用配置不同的部分(自定义配置)
if (useCommonConfig && commonConfigSnippet.trim()) {
try {
const currentConfig = JSON.parse(values.settingsConfig.trim());
const commonConfig = JSON.parse(commonConfigSnippet.trim());
if (isPlainObject(currentConfig) && isPlainObject(commonConfig)) {
const { customConfig } = extractDifference(
currentConfig,
commonConfig,
);
settingsConfig = JSON.stringify(customConfig, null, 2);
} else {
settingsConfig = values.settingsConfig.trim();
}
} catch {
// 如果解析失败,使用原始配置
settingsConfig = values.settingsConfig.trim();
}
} else {
settingsConfig = values.settingsConfig.trim();
}
settingsConfig = values.settingsConfig.trim();
}
const payload: ProviderFormValues = {
@@ -1547,16 +1307,6 @@ export function ProviderForm({
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
const existingCommonConfigEnabledByApp = baseMeta?.commonConfigEnabledByApp;
const commonConfigEnabledByApp = supportsCommonConfig
? ({
...(existingCommonConfigEnabledByApp ?? {}),
[appId]: commonConfigEnabled ?? false,
} as CommonConfigEnabledByApp)
: existingCommonConfigEnabledByApp;
const shouldPersistCommonConfigEnabledByApp =
supportsCommonConfig || existingCommonConfigEnabledByApp !== undefined;
payload.meta = {
...(baseMeta ?? {}),
endpointAutoSelect,
@@ -1569,10 +1319,6 @@ export function ProviderForm({
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
? pricingConfig.pricingModelSource
: undefined,
...(shouldPersistCommonConfigEnabledByApp
? { commonConfigEnabledByApp }
: {}),
// Claude API 格式(仅非官方 Claude 供应商使用)
apiFormat:
appId === "claude" && category !== "official"
? localApiFormat
@@ -2022,7 +1768,6 @@ export function ProviderForm({
configError={codexConfigError}
onExtract={handleCodexExtract}
isExtracting={isCodexExtracting}
finalConfig={codexFinalConfig}
/>
{settingsConfigErrorField}
</>
@@ -2044,7 +1789,6 @@ export function ProviderForm({
configError={geminiConfigError}
onExtract={handleGeminiExtract}
isExtracting={isGeminiExtracting}
finalEnv={envObjToString(geminiFinalEnv)}
/>
{settingsConfigErrorField}
</>
@@ -2099,7 +1843,6 @@ export function ProviderForm({
onModalClose={() => setIsCommonConfigModalOpen(false)}
onExtract={handleClaudeExtract}
isExtracting={isClaudeExtracting}
finalConfig={claudeFinalConfig}
/>
{settingsConfigErrorField}
</>
@@ -6,9 +6,9 @@ export { useCodexConfigState } from "./useCodexConfigState";
export { useApiKeyLink } from "./useApiKeyLink";
export { useCustomEndpoints } from "./useCustomEndpoints";
export { useTemplateValues } from "./useTemplateValues";
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
export { useCodexCommonConfig } from "./useCodexCommonConfig";
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
export { useCodexTomlValidation } from "./useCodexTomlValidation";
export { useGeminiConfigState } from "./useGeminiConfigState";
// Common Config Hooks
export { useCodexCommonConfig } from "./useCodexCommonConfig";
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
@@ -1,195 +1,308 @@
import { useState, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
updateTomlCommonConfigSnippet,
hasTomlCommonConfigSnippet,
} from "@/utils/providerConfigUtils";
import { configApi } from "@/lib/api";
import {
useCommonConfigBase,
type UseCommonConfigBaseReturn,
} from "@/hooks/useCommonConfigBase";
import {
codexAdapter,
preserveCodexConfigFormat,
} from "@/hooks/commonConfigAdapters";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import type { ProviderMeta } from "@/types";
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
# Add your common TOML configuration here`;
interface UseCodexCommonConfigProps {
/**
* 当前 Codex 配置(可能是纯 TOML 或 JSON wrapper 格式)
*/
codexConfig: string;
/**
* 配置变化回调
*/
onConfigChange: (config: string) => void;
/**
* 初始数据(编辑模式)
*/
initialData?: {
settingsConfig?: Record<string, unknown>;
meta?: ProviderMeta;
};
/**
* 当前选中的预设 ID
*/
selectedPresetId?: string;
/**
* 当前正在编辑的供应商 ID
*/
currentProviderId?: string;
}
export interface UseCodexCommonConfigReturn {
/** 是否启用通用配置 */
useCommonConfig: boolean;
/** 通用配置片段 (TOML 格式) */
commonConfigSnippet: string;
/** 通用配置错误信息 */
commonConfigError: string;
/** 是否正在加载 */
isLoading: boolean;
/** 是否正在提取 */
isExtracting: boolean;
/** 通用配置开关处理函数 */
handleCommonConfigToggle: (checked: boolean) => void;
/** 通用配置片段变化处理函数 */
handleCommonConfigSnippetChange: (snippet: string) => void;
/** 从当前配置提取通用配置 */
handleExtract: () => Promise<void>;
/** 最终配置(运行时合并结果,只读) */
finalConfig: string;
/** 是否有待保存的通用配置变更 */
hasUnsavedCommonConfig: boolean;
/** 获取待保存的通用配置片段(用于 handleSubmit */
getPendingCommonConfigSnippet: () => string | null;
/** 标记通用配置已保存 */
markCommonConfigSaved: () => void;
}
/**
* 管理 Codex 通用配置片段
*
* 基于 useCommonConfigBase 泛型 Hook + Codex TOML 适配器实现。
* 额外处理 Codex 双格式(纯 TOML / JSON wrapper)的写回逻辑。
* 管理 Codex 通用配置片段 (TOML 格式)
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
*/
export function useCodexCommonConfig({
codexConfig,
onConfigChange,
initialData,
selectedPresetId,
}: UseCodexCommonConfigProps): UseCodexCommonConfigReturn {
}: UseCodexCommonConfigProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 额外的 isExtracting 状态(base hook 的 handleExtract 不适用于 Codex
const [localIsExtracting, setLocalIsExtracting] = useState(false);
const [localExtractError, setLocalExtractError] = useState("");
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
const base: UseCommonConfigBaseReturn<string> = useCommonConfigBase({
adapter: codexAdapter,
inputValue: codexConfig,
onInputChange: onConfigChange,
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
hasInitializedNewMode.current = false;
}, [selectedPresetId]);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("codex");
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else {
// 如果 config.json 中没有,尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
// 迁移到 config.json
await configApi.setCommonConfigSnippet("codex", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
}
} catch (error) {
console.error("加载 Codex 通用配置失败:", error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, []);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (initialData?.settingsConfig && !isLoading) {
const config =
typeof initialData.settingsConfig.config === "string"
? initialData.settingsConfig.config
: "";
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
setUseCommonConfig(hasCommon);
}
}, [initialData, commonConfigSnippet, isLoading]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
// 检查 TOML 片段是否有实质内容(不只是注释和空行)
const lines = commonConfigSnippet.split("\n");
const hasContent = lines.some((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith("#");
});
if (hasContent) {
setUseCommonConfig(true);
// 合并通用配置到当前配置
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
true,
);
if (!error) {
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
}
}
}, [
initialData,
selectedPresetId,
});
commonConfigSnippet,
isLoading,
codexConfig,
onConfigChange,
]);
// Codex 自定义 handleExtract:处理双格式写回 + 状态管理
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
const { updatedConfig, error: snippetError } =
updateTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
checked,
);
if (snippetError) {
setCommonConfigError(snippetError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[codexConfig, commonConfigSnippet, onConfigChange],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
// 保存到 config.json(清空)
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
console.error("保存 Codex 通用配置失败:", error);
setCommonConfigError(
t("codexConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const { updatedConfig } = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false,
);
onConfigChange(updatedConfig);
setUseCommonConfig(false);
}
return;
}
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
setCommonConfigError("");
// 保存到 config.json
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
console.error("保存 Codex 通用配置失败:", error);
setCommonConfigError(
t("codexConfig.saveFailed", { error: String(error) }),
);
});
// 若当前启用通用配置,需要替换为最新片段
if (useCommonConfig) {
const removeResult = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false,
);
if (removeResult.error) {
setCommonConfigError(removeResult.error);
return;
}
const addResult = updateTomlCommonConfigSnippet(
removeResult.updatedConfig,
value,
true,
);
if (addResult.error) {
setCommonConfigError(addResult.error);
return;
}
// 标记正在通过通用配置更新,避免触发状态检查
isUpdatingFromCommonConfig.current = true;
onConfigChange(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
},
[commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
);
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const hasCommon = hasTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}, [codexConfig, commonConfigSnippet, isLoading]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
setLocalIsExtracting(true);
setLocalExtractError("");
setIsExtracting(true);
setCommonConfigError("");
try {
const request = codexAdapter.buildExtractRequest(base.finalValue);
const extracted = await configApi.extractCommonConfigSnippet(
"codex",
request,
);
const extracted = await configApi.extractCommonConfigSnippet("codex", {
settingsConfig: JSON.stringify({
config: codexConfig ?? "",
}),
});
if (!extracted || !extracted.trim()) {
setLocalExtractError(t("codexConfig.extractNoCommonConfig"));
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
return;
}
// 验证 TOML 格式
const parseResult = codexAdapter.parseSnippet(extracted);
if (parseResult.error || parseResult.config === null) {
setLocalExtractError(
t("codexConfig.extractedTomlInvalid", {
defaultValue: "提取的配置 TOML 格式错误",
}),
);
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 从 config 中移除与 extracted 相同的部分
const customToml = codexAdapter.parseInput(codexConfig);
const diffResult = extractTomlDifference(customToml, extracted);
if (diffResult.error) {
// 差异提取失败,显示错误而不是静默吞掉
setLocalExtractError(
t("codexConfig.extractDiffFailed", {
error: diffResult.error,
defaultValue: `差异提取失败: ${diffResult.error}`,
}),
);
return;
}
// 更新 snippet 状态(在差异提取成功后再更新,避免状态不一致)
base.handleCommonConfigSnippetChange(extracted);
// 使用共享的格式保留函数写回
const preserveResult = preserveCodexConfigFormat(
codexConfig,
diffResult.customToml,
);
if (preserveResult.error) {
// 格式保留失败,显示错误
setLocalExtractError(
t("codexConfig.preserveFormatFailed", {
error: preserveResult.error,
defaultValue: `配置格式保留失败: ${preserveResult.error}`,
}),
);
return;
}
onConfigChange(preserveResult.config);
toast.success(
t("codexConfig.extractSuccessNeedSave", {
defaultValue: "已提取通用配置,点击保存按钮完成保存",
}),
);
// 保存到后端
await configApi.setCommonConfigSnippet("codex", extracted);
} catch (error) {
console.error("提取 Codex 通用配置失败:", error);
setLocalExtractError(
t("codexConfig.extractFailed", {
error: String(error),
defaultValue: "提取失败",
}),
setCommonConfigError(
t("codexConfig.extractFailed", { error: String(error) }),
);
} finally {
setLocalIsExtracting(false);
setIsExtracting(false);
}
}, [base, codexConfig, onConfigChange, t]);
// 合并 error:优先显示 extract 错误,其次是 base 的错误
const combinedError = localExtractError || base.commonConfigError;
}, [codexConfig, t]);
return {
useCommonConfig: base.useCommonConfig,
commonConfigSnippet: base.commonConfigSnippet,
commonConfigError: combinedError,
isLoading: base.isLoading,
isExtracting: localIsExtracting,
handleCommonConfigToggle: base.handleCommonConfigToggle,
handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange,
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
finalConfig: base.finalValue,
hasUnsavedCommonConfig: base.hasUnsavedCommonConfig,
getPendingCommonConfigSnippet: base.getPendingCommonConfigSnippet,
markCommonConfigSaved: base.markCommonConfigSaved,
};
}
@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
extractCodexBaseUrl,
setCodexBaseUrl as setCodexBaseUrlInConfig,
@@ -19,7 +18,6 @@ interface UseCodexConfigStateProps {
* Codex 配置包含两部分:auth.json (JSON) 和 config.toml (TOML 字符串)
*/
export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
const { t } = useTranslation();
const [codexAuth, setCodexAuthState] = useState("");
const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
@@ -111,21 +109,18 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
}, [codexAuth, codexApiKey]);
// 验证 Codex Auth JSON
const validateCodexAuth = useCallback(
(value: string): string => {
if (!value.trim()) return "";
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return t("providerForm.authJsonRequired");
}
return "";
} catch {
return t("providerForm.authJsonError");
const validateCodexAuth = useCallback((value: string): string => {
if (!value.trim()) return "";
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "Auth JSON must be an object";
}
},
[t],
);
return "";
} catch {
return "Invalid JSON format";
}
}, []);
// 设置 auth 并验证
const setCodexAuth = useCallback(
@@ -0,0 +1,331 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
updateCommonConfigSnippet,
hasCommonConfigSnippet,
validateJsonConfig,
} from "@/utils/providerConfigUtils";
import { configApi } from "@/lib/api";
const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
"includeCoAuthoredBy": false
}`;
interface UseCommonConfigSnippetProps {
settingsConfig: string;
onConfigChange: (config: string) => void;
initialData?: {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
/** When false, the hook skips all logic and returns disabled state. Default: true */
enabled?: boolean;
}
/**
* 管理 Claude 通用配置片段
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
*/
export function useCommonConfigSnippet({
settingsConfig,
onConfigChange,
initialData,
selectedPresetId,
enabled = true,
}: UseCommonConfigSnippetProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
if (!enabled) return;
hasInitializedNewMode.current = false;
}, [selectedPresetId, enabled]);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
if (!enabled) {
setIsLoading(false);
return;
}
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("claude");
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else {
// 如果 config.json 中没有,尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
// 迁移到 config.json
await configApi.setCommonConfigSnippet("claude", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Claude 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
}
} catch (error) {
console.error("加载通用配置失败:", error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, [enabled]);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (!enabled) return;
if (initialData && !isLoading) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCommon = hasCommonConfigSnippet(
configString,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}
}, [enabled, initialData, commonConfigSnippet, isLoading]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
if (!enabled) return;
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
// 检查片段是否有实质内容
try {
const snippetObj = JSON.parse(commonConfigSnippet);
const hasContent = Object.keys(snippetObj).length > 0;
if (hasContent) {
setUseCommonConfig(true);
// 合并通用配置到当前配置
const { updatedConfig, error } = updateCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
true,
);
if (!error) {
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
}
} catch {
// ignore parse error
}
}
}, [
enabled,
initialData,
commonConfigSnippet,
isLoading,
settingsConfig,
onConfigChange,
]);
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
checked,
);
if (snippetError) {
setCommonConfigError(snippetError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[settingsConfig, commonConfigSnippet, onConfigChange],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
// 保存到 config.json(清空)
configApi.setCommonConfigSnippet("claude", "").catch((error) => {
console.error("保存通用配置失败:", error);
setCommonConfigError(
t("claudeConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const { updatedConfig } = updateCommonConfigSnippet(
settingsConfig,
previousSnippet,
false,
);
onConfigChange(updatedConfig);
setUseCommonConfig(false);
}
return;
}
// 验证JSON格式
const validationError = validateJsonConfig(value, "通用配置片段");
if (validationError) {
setCommonConfigError(validationError);
} else {
setCommonConfigError("");
// 保存到 config.json
configApi.setCommonConfigSnippet("claude", value).catch((error) => {
console.error("保存通用配置失败:", error);
setCommonConfigError(
t("claudeConfig.saveFailed", { error: String(error) }),
);
});
}
// 若当前启用通用配置且格式正确,需要替换为最新片段
if (useCommonConfig && !validationError) {
const removeResult = updateCommonConfigSnippet(
settingsConfig,
previousSnippet,
false,
);
if (removeResult.error) {
setCommonConfigError(removeResult.error);
return;
}
const addResult = updateCommonConfigSnippet(
removeResult.updatedConfig,
value,
true,
);
if (addResult.error) {
setCommonConfigError(addResult.error);
return;
}
// 标记正在通过通用配置更新,避免触发状态检查
isUpdatingFromCommonConfig.current = true;
onConfigChange(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
},
[commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange],
);
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (!enabled) return;
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const hasCommon = hasCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const extracted = await configApi.extractCommonConfigSnippet("claude", {
settingsConfig,
});
if (!extracted || extracted === "{}") {
setCommonConfigError(t("claudeConfig.extractNoCommonConfig"));
return;
}
// 验证 JSON 格式
const validationError = validateJsonConfig(extracted, "提取的配置");
if (validationError) {
setCommonConfigError(validationError);
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 保存到后端
await configApi.setCommonConfigSnippet("claude", extracted);
} catch (error) {
console.error("提取通用配置失败:", error);
setCommonConfigError(
t("claudeConfig.extractFailed", { error: String(error) }),
);
} finally {
setIsExtracting(false);
}
}, [settingsConfig, t]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
};
}
@@ -0,0 +1,465 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { configApi } from "@/lib/api";
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
] as const;
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
interface UseGeminiCommonConfigProps {
envValue: string;
onEnvChange: (env: string) => void;
envStringToObj: (envString: string) => Record<string, string>;
envObjToString: (envObj: Record<string, unknown>) => string;
initialData?: {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
/**
* 管理 Gemini 通用配置片段 (JSON 格式)
* 写入 Gemini 的 .env,但会排除以下敏感字段:
* - GOOGLE_GEMINI_BASE_URL
* - GEMINI_API_KEY
*/
export function useGeminiCommonConfig({
envValue,
onEnvChange,
envStringToObj,
envObjToString,
initialData,
selectedPresetId,
}: UseGeminiCommonConfigProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
hasInitializedNewMode.current = false;
}, [selectedPresetId]);
const parseSnippetEnv = useCallback(
(
snippetString: string,
): { env: Record<string, string>; error?: string } => {
const trimmed = snippetString.trim();
if (!trimmed) {
return { env: {} };
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
if (!isPlainObject(parsed)) {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
const keys = Object.keys(parsed);
const forbiddenKeys = keys.filter((key) =>
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
);
if (forbiddenKeys.length > 0) {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidKeys", {
keys: forbiddenKeys.join(", "),
}),
};
}
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== "string") {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidValues"),
};
}
const normalized = value.trim();
if (!normalized) continue;
env[key] = normalized;
}
return { env };
},
[t],
);
const hasEnvCommonConfigSnippet = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const entries = Object.entries(snippetEnv);
if (entries.length === 0) return false;
return entries.every(([key, value]) => envObj[key] === value);
},
[],
);
const applySnippetToEnv = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const updated = { ...envObj };
for (const [key, value] of Object.entries(snippetEnv)) {
if (typeof value === "string") {
updated[key] = value;
}
}
return updated;
},
[],
);
const removeSnippetFromEnv = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const updated = { ...envObj };
for (const [key, value] of Object.entries(snippetEnv)) {
if (typeof value === "string" && updated[key] === value) {
delete updated[key];
}
}
return updated;
},
[],
);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("gemini");
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else {
// 如果 config.json 中没有,尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
const parsed = parseSnippetEnv(legacySnippet);
if (parsed.error) {
console.warn(
"[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移",
);
return;
}
// 迁移到 config.json
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
}
} catch (error) {
console.error("加载 Gemini 通用配置失败:", error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, [parseSnippetEnv]);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (initialData?.settingsConfig && !isLoading) {
try {
const env =
isPlainObject(initialData.settingsConfig.env) &&
Object.keys(initialData.settingsConfig.env).length > 0
? (initialData.settingsConfig.env as Record<string, string>)
: {};
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const hasCommon = hasEnvCommonConfigSnippet(
env,
parsed.env as Record<string, string>,
);
setUseCommonConfig(hasCommon);
} catch {
// ignore parse error
}
}
}, [
commonConfigSnippet,
hasEnvCommonConfigSnippet,
initialData,
isLoading,
parseSnippetEnv,
]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const hasContent = Object.keys(parsed.env).length > 0;
if (!hasContent) return;
setUseCommonConfig(true);
const currentEnv = envStringToObj(envValue);
const merged = applySnippetToEnv(currentEnv, parsed.env);
const nextEnvString = envObjToString(merged);
isUpdatingFromCommonConfig.current = true;
onEnvChange(nextEnvString);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
}, [
initialData,
isLoading,
commonConfigSnippet,
envValue,
envStringToObj,
envObjToString,
applySnippetToEnv,
onEnvChange,
parseSnippetEnv,
]);
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) {
setCommonConfigError(parsed.error);
setUseCommonConfig(false);
return;
}
if (Object.keys(parsed.env).length === 0) {
setCommonConfigError(t("geminiConfig.noCommonConfigToApply"));
setUseCommonConfig(false);
return;
}
const currentEnv = envStringToObj(envValue);
const updatedEnvObj = checked
? applySnippetToEnv(currentEnv, parsed.env)
: removeSnippetFromEnv(currentEnv, parsed.env);
setCommonConfigError("");
setUseCommonConfig(checked);
isUpdatingFromCommonConfig.current = true;
onEnvChange(envObjToString(updatedEnvObj));
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[
applySnippetToEnv,
commonConfigSnippet,
envObjToString,
envStringToObj,
envValue,
onEnvChange,
parseSnippetEnv,
removeSnippetFromEnv,
t,
],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
// 保存到 config.json(清空)
configApi.setCommonConfigSnippet("gemini", "").catch((error) => {
console.error("保存 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const parsed = parseSnippetEnv(previousSnippet);
if (!parsed.error && Object.keys(parsed.env).length > 0) {
const currentEnv = envStringToObj(envValue);
const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env);
onEnvChange(envObjToString(updatedEnv));
}
setUseCommonConfig(false);
}
return;
}
// 校验 JSON 格式
const parsed = parseSnippetEnv(value);
if (parsed.error) {
setCommonConfigError(parsed.error);
return;
}
setCommonConfigError("");
configApi.setCommonConfigSnippet("gemini", value).catch((error) => {
console.error("保存 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
// 若当前启用通用配置,需要替换为最新片段
if (useCommonConfig) {
const prevParsed = parseSnippetEnv(previousSnippet);
const prevEnv = prevParsed.error ? {} : prevParsed.env;
const nextEnv = parsed.env;
const currentEnv = envStringToObj(envValue);
const withoutOld =
Object.keys(prevEnv).length > 0
? removeSnippetFromEnv(currentEnv, prevEnv)
: currentEnv;
const withNew =
Object.keys(nextEnv).length > 0
? applySnippetToEnv(withoutOld, nextEnv)
: withoutOld;
isUpdatingFromCommonConfig.current = true;
onEnvChange(envObjToString(withNew));
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
},
[
applySnippetToEnv,
commonConfigSnippet,
envObjToString,
envStringToObj,
envValue,
onEnvChange,
parseSnippetEnv,
removeSnippetFromEnv,
t,
useCommonConfig,
],
);
// 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const envObj = envStringToObj(envValue);
setUseCommonConfig(
hasEnvCommonConfigSnippet(envObj, parsed.env as Record<string, string>),
);
}, [
envValue,
commonConfigSnippet,
envStringToObj,
hasEnvCommonConfigSnippet,
isLoading,
parseSnippetEnv,
]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
settingsConfig: JSON.stringify({
env: envStringToObj(envValue),
}),
});
if (!extracted || extracted === "{}") {
setCommonConfigError(t("geminiConfig.extractNoCommonConfig"));
return;
}
// 验证 JSON 格式
const parsed = parseSnippetEnv(extracted);
if (parsed.error) {
setCommonConfigError(t("geminiConfig.extractedConfigInvalid"));
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 保存到后端
await configApi.setCommonConfigSnippet("gemini", extracted);
} catch (error) {
console.error("提取 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.extractFailed", { error: String(error) }),
);
} finally {
setIsExtracting(false);
}
}, [envStringToObj, envValue, parseSnippetEnv, t]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
};
}
@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
interface UseGeminiConfigStateProps {
initialData?: {
@@ -14,7 +13,6 @@ interface UseGeminiConfigStateProps {
export function useGeminiConfigState({
initialData,
}: UseGeminiConfigStateProps) {
const { t } = useTranslation();
const [geminiEnv, setGeminiEnvState] = useState("");
const [geminiConfig, setGeminiConfigState] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
@@ -121,21 +119,18 @@ export function useGeminiConfigState({
}, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);
// 验证 Gemini Config JSON
const validateGeminiConfig = useCallback(
(value: string): string => {
if (!value.trim()) return ""; // 空值允许
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return "";
}
return t("providerForm.configJsonError");
} catch {
return t("providerForm.configJsonError");
const validateGeminiConfig = useCallback((value: string): string => {
if (!value.trim()) return ""; // 空值允许
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return "";
}
},
[t],
);
return "Config must be a JSON object";
} catch {
return "Invalid JSON format";
}
}, []);
// 设置 env
const setGeminiEnv = useCallback((value: string) => {
@@ -10,7 +10,6 @@ export function RectifierConfigPanel() {
const [config, setConfig] = useState<RectifierConfig>({
enabled: true,
requestThinkingSignature: true,
requestThinkingBudget: true,
});
const [isLoading, setIsLoading] = useState(true);
@@ -70,21 +69,6 @@ export function RectifierConfigPanel() {
}
/>
</div>
<div className="flex items-center justify-between pl-4">
<div className="space-y-0.5">
<Label>{t("settings.advanced.rectifier.thinkingBudget")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.rectifier.thinkingBudgetDescription")}
</p>
</div>
<Switch
checked={config.requestThinkingBudget}
disabled={!config.enabled}
onCheckedChange={(checked) =>
handleChange({ requestThinkingBudget: checked })
}
/>
</div>
</div>
</div>
);
-504
View File
@@ -1,504 +0,0 @@
/**
* 通用配置格式适配器
*
* 提供 Claude (JSON), Codex (TOML), Gemini (ENV/JSON) 三种格式的适配器实现。
*/
import {
computeFinalConfig,
extractDifference,
isPlainObject,
isSubset,
} from "@/utils/configMerge";
import {
computeFinalTomlConfig,
extractTomlDifference,
safeParseToml,
} from "@/utils/tomlConfigMerge";
import {
parseGeminiCommonConfigSnippet,
GEMINI_CONFIG_ERROR_CODES,
} from "@/utils/providerConfigUtils";
import type {
CommonConfigAdapter,
ParseResult,
ExtractResult,
} from "./useCommonConfigBase";
// Re-export from utils for backward compatibility
export { hasContentByAppType } from "@/utils/commonConfigDetection";
import { hasGeminiContent } from "@/utils/commonConfigDetection";
// ============================================================================
// Claude Adapter (JSON)
// ============================================================================
const CLAUDE_LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
const CLAUDE_DEFAULT_SNIPPET = `{
"includeCoAuthoredBy": false
}`;
export const claudeAdapter: CommonConfigAdapter<
Record<string, unknown>,
string
> = {
appKey: "claude",
defaultSnippet: CLAUDE_DEFAULT_SNIPPET,
legacyStorageKey: CLAUDE_LEGACY_STORAGE_KEY,
parseSnippet: (snippet: string): ParseResult<Record<string, unknown>> => {
const trimmed = snippet.trim();
if (!trimmed) {
return { config: {}, error: null };
}
try {
const parsed = JSON.parse(trimmed);
if (!isPlainObject(parsed)) {
return { config: null, error: "JSON 格式错误:不是对象" };
}
return { config: parsed, error: null };
} catch {
return { config: null, error: "JSON 格式错误" };
}
},
hasValidContent: (snippet: string): boolean => {
const result = claudeAdapter.parseSnippet(snippet);
return (
!result.error &&
result.config !== null &&
Object.keys(result.config).length > 0
);
},
hasContent: (configStr: string, snippetStr: string): boolean => {
try {
if (!snippetStr.trim()) return false;
const config = configStr ? JSON.parse(configStr) : {};
const snippet = JSON.parse(snippetStr);
if (!isPlainObject(snippet)) return false;
return isSubset(config, snippet);
} catch {
return false;
}
},
getApplyError: (snippet: string, t): string => {
const result = claudeAdapter.parseSnippet(snippet);
if (result.error) {
return result.error;
}
if (result.config === null || Object.keys(result.config).length === 0) {
return t("claudeConfig.noCommonConfigToApply");
}
return "";
},
parseInput: (input: string): Record<string, unknown> => {
try {
const parsed = JSON.parse(input || "{}");
return isPlainObject(parsed) ? parsed : {};
} catch {
return {};
}
},
computeFinal: (
custom: Record<string, unknown>,
common: Record<string, unknown>,
enabled: boolean,
): string => {
if (!enabled || Object.keys(common).length === 0) {
return JSON.stringify(custom, null, 2);
}
const merged = computeFinalConfig(custom, common, true);
return JSON.stringify(merged, null, 2);
},
extractDiff: (
custom: Record<string, unknown>,
common: Record<string, unknown>,
): ExtractResult<Record<string, unknown>> => {
const result = extractDifference(custom, common);
return {
custom: result.customConfig,
hasCommonKeys: result.hasCommonKeys,
};
},
serializeOutput: (config: Record<string, unknown>): string => {
return JSON.stringify(config, null, 2);
},
buildExtractRequest: (finalValue: string): { settingsConfig: string } => {
return { settingsConfig: finalValue };
},
};
// ============================================================================
// Codex Adapter (TOML)
// ============================================================================
const CODEX_LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const CODEX_DEFAULT_SNIPPET = `# Common Codex config
# Add your common TOML configuration here`;
/** 检查 TOML 是否有实质内容(非空、非纯注释) */
function hasTomlContent(toml: string): boolean {
const lines = toml.split("\n");
return lines.some((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith("#");
});
}
/** 校验 TOML 格式 */
function validateTomlFormat(tomlText: string): string | null {
if (!hasTomlContent(tomlText)) {
return null; // 空或纯注释是合法的
}
const result = safeParseToml(tomlText);
return result.error;
}
/**
* 从 codexConfig 提取 config 字段(TOML 格式)
* 支持两种格式:
* 1. 直接的 TOML 字符串
* 2. JSON 字符串 { auth: {...}, config: "TOML" }
*
* 注意:如果 config 字段存在但不是 string,这表示 schema 异常
* 此时返回空字符串并打印警告,避免静默处理错误数据
*/
function extractConfigToml(configInput: string): string {
if (!configInput || !configInput.trim()) {
return "";
}
// 尝试解析为 JSONwrapper 格式)
try {
const parsed = JSON.parse(configInput);
if (typeof parsed?.config === "string") {
return parsed.config;
}
// 如果是 JSON 对象
if (typeof parsed === "object" && parsed !== null) {
// config 字段存在但不是 string,这是 schema 异常
if ("config" in parsed && typeof parsed.config !== "string") {
console.warn(
`[extractConfigToml] config field is ${typeof parsed.config}, expected string`,
);
}
// JSON 对象没有有效的 config 字段,返回空(无 TOML 可提取)
return "";
}
} catch {
// JSON 解析失败,说明是直接的 TOML 字符串
}
return configInput;
}
/**
* 检测 Codex 配置是否是 JSON wrapper 格式
* @returns 如果是 JSON wrapper 返回解析后的对象,否则返回 null
*/
function detectJsonWrapperFormat(
codexConfig: string,
): { auth?: unknown; config?: unknown; [key: string]: unknown } | null {
try {
const parsed = JSON.parse(codexConfig);
// 只有当解析结果是对象时才认为是 JSON wrapper
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
return parsed;
}
} catch {
// 不是 JSON
}
return null;
}
/**
* Codex 配置格式保留结果
*/
export interface PreserveCodexConfigResult {
/** 格式化后的配置字符串 */
config: string;
/** 错误信息(如果有) */
error?: string;
}
/**
* 保留原始格式写回 Codex 配置
*
* 如果原始配置是 JSON wrapper 格式,则更新 config 字段并返回 JSON
* 如果原始配置是纯 TOML 格式,则直接返回 TOML
*
* @param originalConfig - 原始配置字符串
* @param updatedToml - 更新后的 TOML 内容
* @returns 格式化后的配置字符串和可能的错误
*/
export function preserveCodexConfigFormat(
originalConfig: string,
updatedToml: string,
): PreserveCodexConfigResult {
const jsonWrapper = detectJsonWrapperFormat(originalConfig);
if (jsonWrapper) {
// 是 JSON wrapper 格式
if ("config" in jsonWrapper) {
// 存在 config 字段
if (typeof jsonWrapper.config !== "string") {
// config 字段不是 string,这是意外的 schema
// 返回原配置并报错,避免数据丢失
return {
config: originalConfig,
error: `Codex config field is ${typeof jsonWrapper.config}, expected string. Cannot update safely.`,
};
}
// 正常更新 config 字段
jsonWrapper.config = updatedToml;
return { config: JSON.stringify(jsonWrapper, null, 2) };
} else {
// 没有 config 字段,但是是 JSON 对象(可能包含 auth 等其他字段)
// 添加 config 字段
jsonWrapper.config = updatedToml;
return { config: JSON.stringify(jsonWrapper, null, 2) };
}
}
// 纯 TOML 格式
return { config: updatedToml };
}
export const codexAdapter: CommonConfigAdapter<string, string> = {
appKey: "codex",
defaultSnippet: CODEX_DEFAULT_SNIPPET,
legacyStorageKey: CODEX_LEGACY_STORAGE_KEY,
parseSnippet: (snippet: string): ParseResult<string> => {
const error = validateTomlFormat(snippet);
if (error) {
return { config: null, error };
}
return { config: snippet, error: null };
},
hasValidContent: (snippet: string): boolean => {
return hasTomlContent(snippet) && !validateTomlFormat(snippet);
},
hasContent: (configStr: string, snippetStr: string): boolean => {
if (!snippetStr.trim()) return false;
// 解析配置(可能是纯 TOML 或 JSON wrapper 格式)
const configToml = extractConfigToml(configStr);
const configParsed = safeParseToml(configToml);
const snippetParsed = safeParseToml(snippetStr);
if (configParsed.error || !configParsed.config) return false;
if (snippetParsed.error || !snippetParsed.config) return false;
// 使用 isSubset 检查 snippet 是否是 config 的子集
return isSubset(configParsed.config, snippetParsed.config);
},
getApplyError: (snippet: string, t): string => {
if (!hasTomlContent(snippet)) {
return t("codexConfig.noCommonConfigToApply");
}
const error = validateTomlFormat(snippet);
if (error) {
return t("codexConfig.tomlFormatError", {
defaultValue: "TOML 格式错误",
});
}
return "";
},
parseInput: (input: string): string => {
return extractConfigToml(input);
},
computeFinal: (custom: string, common: string, enabled: boolean): string => {
if (!enabled || !hasTomlContent(common)) {
return custom;
}
const result = computeFinalTomlConfig(custom, common, true);
return result.error ? custom : result.finalConfig;
},
extractDiff: (custom: string, common: string): ExtractResult<string> => {
const result = extractTomlDifference(custom, common);
return {
custom: result.customToml,
hasCommonKeys: result.hasCommonKeys,
error: result.error,
};
},
serializeOutput: (config: string): string => {
return config;
},
buildExtractRequest: (finalValue: string): { settingsConfig: string } => {
return {
settingsConfig: JSON.stringify({ config: finalValue ?? "" }),
};
},
};
// ============================================================================
// Gemini Adapter (ENV/JSON)
// ============================================================================
const GEMINI_LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
const GEMINI_DEFAULT_SNIPPET = "";
export interface GeminiAdapterOptions {
/** 字符串转对象 */
envStringToObj: (envString: string) => Record<string, string>;
/** 对象转字符串 */
envObjToString: (envObj: Record<string, unknown>) => string;
}
/**
* 创建 Gemini 适配器
* 需要传入 env 转换函数,因为这些函数依赖于外部实现
*/
export function createGeminiAdapter(
options: GeminiAdapterOptions,
): CommonConfigAdapter<Record<string, string>, Record<string, string>> {
const { envStringToObj, envObjToString } = options;
return {
appKey: "gemini",
defaultSnippet: GEMINI_DEFAULT_SNIPPET,
legacyStorageKey: GEMINI_LEGACY_STORAGE_KEY,
parseSnippet: (snippet: string): ParseResult<Record<string, string>> => {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: true,
});
if (result.error) {
return { config: null, error: result.error };
}
return { config: result.env, error: null };
},
hasValidContent: (snippet: string): boolean => {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: true,
});
return !result.error && Object.keys(result.env).length > 0;
},
hasContent: (configStr: string, snippetStr: string): boolean => {
return hasGeminiContent(configStr, snippetStr).hasContent;
},
getApplyError: (snippet: string, t): string => {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: true,
});
if (result.errorInfo) {
// Use structured error info for type-safe handling
switch (result.errorInfo.code) {
case GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS:
return t("geminiConfig.commonConfigInvalidKeys", {
keys: result.errorInfo.keys?.join(", ") ?? "",
});
case GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING:
return t("geminiConfig.commonConfigInvalidValues");
default:
return t("geminiConfig.invalidEnvFormat", {
defaultValue: "配置格式错误",
});
}
}
// Fallback for legacy error string (backward compatibility)
if (result.error) {
return t("geminiConfig.invalidEnvFormat", {
defaultValue: "配置格式错误",
});
}
if (Object.keys(result.env).length === 0) {
return t("geminiConfig.noCommonConfigToApply");
}
return "";
},
parseInput: (input: string): Record<string, string> => {
return envStringToObj(input);
},
computeFinal: (
custom: Record<string, string>,
common: Record<string, string>,
enabled: boolean,
): Record<string, string> => {
if (!enabled || Object.keys(common).length === 0) {
return custom;
}
// 通用配置作为 base,自定义 env 覆盖
const merged = computeFinalConfig(
custom as Record<string, unknown>,
common as Record<string, unknown>,
true,
);
// 转换回 Record<string, string>
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(merged)) {
if (typeof value === "string") {
result[key] = value;
}
}
return result;
},
extractDiff: (
custom: Record<string, string>,
common: Record<string, string>,
): ExtractResult<Record<string, string>> => {
const result = extractDifference(
custom as Record<string, unknown>,
common as Record<string, unknown>,
);
// 转换回 Record<string, string>
const customResult: Record<string, string> = {};
for (const [key, value] of Object.entries(result.customConfig)) {
if (typeof value === "string") {
customResult[key] = value;
}
}
return {
custom: customResult,
hasCommonKeys: result.hasCommonKeys,
};
},
serializeOutput: (config: Record<string, string>): string => {
return envObjToString(config);
},
buildExtractRequest: (
finalValue: Record<string, string>,
): { settingsConfig: string } => {
return {
settingsConfig: JSON.stringify({ env: finalValue }),
};
},
};
}
-494
View File
@@ -1,494 +0,0 @@
/**
* 通用配置管理基础 Hook
*
* 提供加载、切换、保存、提取等通用逻辑,通过 Adapter 注入格式特定处理。
* 支持 Claude (JSON), Codex (TOML), Gemini (ENV/JSON) 三种格式。
*/
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { configApi } from "@/lib/api";
import type { ProviderMeta } from "@/types";
// ============================================================================
// 类型定义
// ============================================================================
/** 应用类型 */
export type CommonConfigAppKey = "claude" | "codex" | "gemini";
/** 解析结果 */
export interface ParseResult<T> {
config: T | null;
error: string | null;
}
/** 合并结果 */
export interface MergeResult<T> {
merged: T;
error?: string;
}
/** 差异提取结果 */
export interface ExtractResult<T> {
custom: T;
hasCommonKeys: boolean;
error?: string;
}
/**
* 格式适配器接口
*
* 每种格式(JSON/TOML/ENV)需要实现此接口
*/
export interface CommonConfigAdapter<TConfig, TFinal> {
/** 应用标识 */
appKey: CommonConfigAppKey;
/** 默认片段内容 */
defaultSnippet: string;
/** localStorage 迁移 key (可选) */
legacyStorageKey?: string;
/**
* 解析片段字符串为配置对象
* @param snippet - 原始片段字符串
* @returns 解析结果
*/
parseSnippet: (snippet: string) => ParseResult<TConfig>;
/**
* 验证片段是否有有效内容(非空、非纯注释)
* @param snippet - 片段字符串
* @returns 是否有有效内容
*/
hasValidContent: (snippet: string) => boolean;
/**
* 检查配置是否包含指定的片段内容
* 用于检测供应商配置是否已应用通用配置
* @param configStr - 供应商的配置字符串 (settingsConfig)
* @param snippetStr - 通用配置片段字符串
* @returns 是否包含片段内容
*/
hasContent: (configStr: string, snippetStr: string) => boolean;
/**
* 获取片段应用错误(用于 toggle 时验证)
* @param snippet - 片段字符串
* @param t - i18n 翻译函数
* @returns 错误信息,无错误返回空字符串
*/
getApplyError: (
snippet: string,
t: (key: string, options?: Record<string, unknown>) => string,
) => string;
/**
* 从表单输入解析当前配置
* @param input - 表单输入值
* @returns 解析后的配置对象
*/
parseInput: (input: string) => TConfig;
/**
* 计算最终合并配置
* @param custom - 自定义配置
* @param common - 通用配置
* @param enabled - 是否启用
* @returns 合并后的最终配置
*/
computeFinal: (custom: TConfig, common: TConfig, enabled: boolean) => TFinal;
/**
* 提取差异(从自定义配置中移除与通用配置相同的部分)
* @param custom - 自定义配置
* @param common - 通用配置
* @returns 差异结果
*/
extractDiff: (custom: TConfig, common: TConfig) => ExtractResult<TConfig>;
/**
* 将配置对象序列化为表单输出
* @param config - 配置对象
* @returns 序列化后的字符串
*/
serializeOutput: (config: TConfig) => string;
/**
* 构建提取 API 的请求参数
* @param finalValue - 最终合并后的值
* @returns API 请求参数
*/
buildExtractRequest: (finalValue: TFinal) => { settingsConfig: string };
}
// ============================================================================
// Props 和 Return 类型
// ============================================================================
export interface UseCommonConfigBaseProps<TConfig, TFinal> {
/** 格式适配器 */
adapter: CommonConfigAdapter<TConfig, TFinal>;
/** 当前表单输入值 */
inputValue: string;
/** 输入变化回调 */
onInputChange: (value: string) => void;
/** 初始数据(编辑模式) */
initialData?: {
settingsConfig?: Record<string, unknown>;
meta?: ProviderMeta;
};
/** 当前选中的预设 ID */
selectedPresetId?: string;
/** 是否启用此 hook(默认 true */
enabled?: boolean;
}
export interface UseCommonConfigBaseReturn<TFinal> {
/** 是否启用通用配置 */
useCommonConfig: boolean;
/** 通用配置片段 */
commonConfigSnippet: string;
/** 通用配置错误信息 */
commonConfigError: string;
/** 是否正在加载 */
isLoading: boolean;
/** 是否正在提取 */
isExtracting: boolean;
/** 通用配置开关处理函数 */
handleCommonConfigToggle: (checked: boolean) => void;
/** 通用配置片段变化处理函数 */
handleCommonConfigSnippetChange: (snippet: string) => void;
/** 从当前配置提取通用配置 */
handleExtract: () => Promise<void>;
/** 最终配置(运行时合并结果,只读) */
finalValue: TFinal;
/** 是否有待保存的通用配置变更 */
hasUnsavedCommonConfig: boolean;
/** 获取待保存的通用配置片段(用于 handleSubmit */
getPendingCommonConfigSnippet: () => string | null;
/** 标记通用配置已保存 */
markCommonConfigSaved: () => void;
}
// ============================================================================
// 基础 Hook 实现
// ============================================================================
export function useCommonConfigBase<TConfig, TFinal>({
adapter,
inputValue,
onInputChange,
initialData,
selectedPresetId,
enabled = true,
}: UseCommonConfigBaseProps<
TConfig,
TFinal
>): UseCommonConfigBaseReturn<TFinal> {
const { t } = useTranslation();
// ============================================================================
// 状态
// ============================================================================
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
adapter.defaultSnippet,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false);
// 初始化跟踪
const hasInitializedEditMode = useRef(false);
const hasInitializedNewMode = useRef(false);
// ============================================================================
// 预设变化时重置初始化标记
// ============================================================================
useEffect(() => {
if (!enabled) return;
hasInitializedNewMode.current = false;
hasInitializedEditMode.current = false;
}, [selectedPresetId, enabled]);
// ============================================================================
// 加载通用配置片段(从数据库,支持 localStorage 迁移)
// ============================================================================
useEffect(() => {
if (!enabled) {
setIsLoading(false);
return;
}
let mounted = true;
const loadSnippet = async () => {
try {
const snippet = await configApi.getCommonConfigSnippet(adapter.appKey);
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else if (adapter.legacyStorageKey && typeof window !== "undefined") {
// 尝试从 localStorage 迁移
try {
const legacySnippet = window.localStorage.getItem(
adapter.legacyStorageKey,
);
if (legacySnippet && legacySnippet.trim()) {
const parsed = adapter.parseSnippet(legacySnippet);
// 只有在解析成功且有有效内容时才迁移
// 这避免了将 "{}" 这样的空 JSON 迁移为"有效配置"
if (!parsed.error && adapter.hasValidContent(legacySnippet)) {
await configApi.setCommonConfigSnippet(
adapter.appKey,
legacySnippet,
);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
window.localStorage.removeItem(adapter.legacyStorageKey);
console.log(
`[迁移] ${adapter.appKey} 通用配置已从 localStorage 迁移到数据库`,
);
} else {
// 解析失败或无有效内容,清理 localStorage 不迁移
window.localStorage.removeItem(adapter.legacyStorageKey);
}
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
} catch (error) {
console.error(`加载 ${adapter.appKey} 通用配置失败:`, error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, [enabled, adapter]);
// ============================================================================
// 编辑模式初始化:从 meta 读取启用状态
// ============================================================================
useEffect(() => {
if (!enabled) return;
if (initialData && !isLoading && !hasInitializedEditMode.current) {
hasInitializedEditMode.current = true;
const metaByApp = initialData.meta?.commonConfigEnabledByApp;
const resolvedMetaEnabled =
metaByApp?.[adapter.appKey] ?? initialData.meta?.commonConfigEnabled;
if (resolvedMetaEnabled !== undefined) {
if (!resolvedMetaEnabled) {
setUseCommonConfig(false);
return;
}
const applyError = adapter.getApplyError(commonConfigSnippet, t);
if (applyError) {
setCommonConfigError(applyError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(true);
} else {
setUseCommonConfig(false);
}
}
}, [enabled, initialData, isLoading, commonConfigSnippet, adapter, t]);
// ============================================================================
// 新建模式初始化:如果通用配置有效,默认启用
// ============================================================================
useEffect(() => {
if (!enabled) return;
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
if (adapter.hasValidContent(commonConfigSnippet)) {
const parsed = adapter.parseSnippet(commonConfigSnippet);
if (!parsed.error && parsed.config !== null) {
setUseCommonConfig(true);
}
}
}
}, [enabled, initialData, commonConfigSnippet, isLoading, adapter]);
// ============================================================================
// 计算最终配置(运行时合并)
// ============================================================================
const finalValue = useMemo((): TFinal => {
const customConfig = adapter.parseInput(inputValue);
if (!enabled || !useCommonConfig) {
return adapter.computeFinal(customConfig, customConfig, false);
}
const snippetParsed = adapter.parseSnippet(commonConfigSnippet);
if (snippetParsed.error || snippetParsed.config === null) {
return adapter.computeFinal(customConfig, customConfig, false);
}
return adapter.computeFinal(customConfig, snippetParsed.config, true);
}, [enabled, inputValue, commonConfigSnippet, useCommonConfig, adapter]);
// ============================================================================
// 处理通用配置开关
// ============================================================================
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
if (checked) {
const applyError = adapter.getApplyError(commonConfigSnippet, t);
if (applyError) {
setCommonConfigError(applyError);
setUseCommonConfig(false);
return;
}
}
setCommonConfigError("");
setUseCommonConfig(checked);
},
[commonConfigSnippet, adapter, t],
);
// ============================================================================
// 处理通用配置片段变化(延迟保存模式)
// ============================================================================
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
setHasUnsavedCommonConfig(true);
return;
}
// 格式校验
const parsed = adapter.parseSnippet(value);
if (parsed.error) {
setCommonConfigError(parsed.error);
return;
}
setCommonConfigError("");
setHasUnsavedCommonConfig(true);
},
[adapter],
);
// ============================================================================
// 从当前最终配置提取通用配置片段
// ============================================================================
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const request = adapter.buildExtractRequest(finalValue);
const extracted = await configApi.extractCommonConfigSnippet(
adapter.appKey,
request,
);
if (
!extracted ||
!extracted.trim() ||
!adapter.hasValidContent(extracted)
) {
setCommonConfigError(
t(`${adapter.appKey}Config.extractNoCommonConfig`, {
defaultValue: "无法提取通用配置",
}),
);
return;
}
// 验证提取结果格式
const extractedParsed = adapter.parseSnippet(extracted);
if (extractedParsed.error || extractedParsed.config === null) {
setCommonConfigError(
t(`${adapter.appKey}Config.extractedConfigInvalid`, {
defaultValue: "提取的配置格式错误",
}),
);
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
setHasUnsavedCommonConfig(true);
// 从自定义配置中移除与提取内容相同的部分
const customConfig = adapter.parseInput(inputValue);
const diffResult = adapter.extractDiff(
customConfig,
extractedParsed.config,
);
if (!diffResult.error) {
onInputChange(adapter.serializeOutput(diffResult.custom));
toast.success(
t(`${adapter.appKey}Config.extractSuccessNeedSave`, {
defaultValue: "已提取通用配置,点击保存按钮完成保存",
}),
);
}
} catch (error) {
console.error(`提取 ${adapter.appKey} 通用配置失败:`, error);
setCommonConfigError(
t(`${adapter.appKey}Config.extractFailed`, {
error: String(error),
defaultValue: "提取失败",
}),
);
} finally {
setIsExtracting(false);
}
}, [adapter, finalValue, inputValue, onInputChange, t]);
// ============================================================================
// 获取待保存的通用配置片段
// ============================================================================
const getPendingCommonConfigSnippet = useCallback(() => {
return hasUnsavedCommonConfig ? commonConfigSnippet : null;
}, [hasUnsavedCommonConfig, commonConfigSnippet]);
// ============================================================================
// 标记通用配置已保存
// ============================================================================
const markCommonConfigSaved = useCallback(() => {
setHasUnsavedCommonConfig(false);
}, []);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
finalValue,
hasUnsavedCommonConfig,
getPendingCommonConfigSnippet,
markCommonConfigSaved,
};
}
-27
View File
@@ -1,27 +0,0 @@
import { useState, useEffect } from "react";
/**
* Hook to track dark mode state by observing document.documentElement class changes.
*
* @returns boolean indicating if dark mode is currently active
*/
export function useDarkMode(): boolean {
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return isDarkMode;
}
+5 -23
View File
@@ -37,8 +37,7 @@
"search": "Search",
"reset": "Reset",
"actions": "Actions",
"deleting": "Deleting...",
"readonly": "Read-only"
"deleting": "Deleting..."
},
"apiKeyInput": {
"placeholder": "Enter API Key",
@@ -55,18 +54,11 @@
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Common Config Snippet",
"commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked",
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
"fullSettingsHint": "Full Claude Code settings.json content",
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"extractSuccessNeedSave": "Common config extracted, click Save to complete",
"saveFailed": "Save failed: {{error}}",
"hidePreview": "Hide merged preview",
"showPreview": "Show merged preview",
"customConfig": "Custom config (overrides common)",
"mergedPreview": "Merged preview (read-only)",
"mergedPreviewHint": "Common config + Custom config = Final config"
"saveFailed": "Save failed: {{error}}"
},
"header": {
"viewOnGithub": "View on GitHub",
@@ -222,9 +214,7 @@
"requestGroup": "Request Rectification",
"responseGroup": "Response Rectification",
"thinkingSignature": "Thinking Signature Rectification",
"thinkingSignatureDescription": "When an Anthropic-type provider returns thinking signature incompatibility or illegal request errors, automatically removes incompatible thinking-related blocks and retries once with the same provider",
"thinkingBudget": "Thinking Budget Rectification",
"thinkingBudgetDescription": "When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024), automatically normalizes thinking to enabled, sets thinking budget to 32000, and raises max_tokens to 64000 if needed, then retries once"
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
},
"logConfig": {
"title": "Log Management",
@@ -546,10 +536,7 @@
"categoryOfficial": "Official",
"categoryCnOfficial": "Opensource Official",
"categoryAggregation": "Aggregation",
"categoryThirdParty": "Third Party",
"commonConfigLoading": "Common config is still loading, please wait before saving",
"commonConfigSyncFailed": "Common config sync failed for some providers. Please retry.",
"saveCommonConfigFailed": "Failed to save common config"
"categoryThirdParty": "Third Party"
},
"endpointTest": {
"title": "API Endpoint Management",
@@ -614,12 +601,10 @@
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
"commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked",
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
"apiUrlLabel": "API Request URL",
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"extractSuccessNeedSave": "Common config extracted, click Save to complete",
"saveFailed": "Save failed: {{error}}"
},
"geminiConfig": {
@@ -634,17 +619,14 @@
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"extractSuccessNeedSave": "Common config extracted, click Save to complete",
"saveFailed": "Save failed: {{error}}",
"extractedConfigInvalid": "Extracted config format is invalid",
"invalidJsonFormat": "Common config snippet format error (must be valid JSON)",
"invalidEnvFormat": "Config format error (use KEY=VALUE format, one per line)",
"commonConfigInvalidKeys": "Common config snippet must not include GOOGLE_GEMINI_BASE_URL or GEMINI_API_KEY (found: {{keys}})",
"commonConfigInvalidValues": "Common config snippet values must be strings",
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
"configMergeFailed": "Config merge failed: {{error}}",
"configReplaceFailed": "Config replace failed: {{error}}",
"forbiddenKeysWarning": "The following keys were filtered (not allowed in common config): {{keys}}"
"configReplaceFailed": "Config replace failed: {{error}}"
},
"opencode": {
"npmPackage": "API Format",
+5 -23
View File
@@ -37,8 +37,7 @@
"search": "検索",
"reset": "リセット",
"actions": "操作",
"deleting": "削除中...",
"readonly": "読み取り専用"
"deleting": "削除中..."
},
"apiKeyInput": {
"placeholder": "API Key を入力",
@@ -55,18 +54,11 @@
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
"fullSettingsHint": "Claude Code の settings.json 全文",
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"extractSuccessNeedSave": "共通設定を抽出しました。保存ボタンをクリックして完了してください",
"saveFailed": "保存に失敗しました: {{error}}",
"hidePreview": "マージプレビューを非表示",
"showPreview": "マージプレビューを表示",
"customConfig": "カスタム設定(共通設定を上書き)",
"mergedPreview": "マージプレビュー(読み取り専用)",
"mergedPreviewHint": "共通設定 + カスタム設定 = 最終設定"
"saveFailed": "保存に失敗しました: {{error}}"
},
"header": {
"viewOnGithub": "GitHub で見る",
@@ -222,9 +214,7 @@
"requestGroup": "リクエスト整流",
"responseGroup": "レスポンス整流",
"thinkingSignature": "Thinking 署名整流",
"thinkingSignatureDescription": "Anthropic タイプのプロバイダーが thinking 署名の非互換性や不正なリクエストエラーを返した場合、互換性のない thinking 関連ブロックを自動削除し、同じプロバイダーで 1 回リトライします",
"thinkingBudget": "Thinking Budget 整流",
"thinkingBudgetDescription": "Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上)を返した場合、thinking を enabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします"
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
},
"logConfig": {
"title": "ログ管理",
@@ -546,10 +536,7 @@
"categoryOfficial": "公式",
"categoryCnOfficial": "オープンソース公式",
"categoryAggregation": "アグリゲーター",
"categoryThirdParty": "サードパーティ",
"commonConfigLoading": "共通設定を読み込み中です。保存する前にお待ちください",
"commonConfigSyncFailed": "共通設定の同期に失敗しました。再試行してください",
"saveCommonConfigFailed": "共通設定の保存に失敗しました"
"categoryThirdParty": "サードパーティ"
},
"endpointTest": {
"title": "API エンドポイント管理",
@@ -614,12 +601,10 @@
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
"apiUrlLabel": "API リクエスト URL",
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"extractSuccessNeedSave": "共通設定を抽出しました。保存ボタンをクリックして完了してください",
"saveFailed": "保存に失敗しました: {{error}}"
},
"geminiConfig": {
@@ -634,17 +619,14 @@
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"extractSuccessNeedSave": "共通設定を抽出しました。保存ボタンをクリックして完了してください",
"saveFailed": "保存に失敗しました: {{error}}",
"extractedConfigInvalid": "抽出した設定のフォーマットが不正です",
"invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)",
"invalidEnvFormat": "設定フォーマットエラー(KEY=VALUE 形式を使用してください、各行に1つ)",
"commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}}",
"commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります",
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
"configReplaceFailed": "設定の置換に失敗しました: {{error}}",
"forbiddenKeysWarning": "以下のキーはフィルタされました(共通設定では許可されていません):{{keys}}"
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
},
"opencode": {
"npmPackage": "API フォーマット",
+5 -23
View File
@@ -37,8 +37,7 @@
"search": "查询",
"reset": "重置",
"actions": "操作",
"deleting": "删除中...",
"readonly": "只读"
"deleting": "删除中..."
},
"apiKeyInput": {
"placeholder": "请输入API Key",
@@ -55,18 +54,11 @@
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑通用配置片段",
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"extractSuccessNeedSave": "已提取通用配置,点击保存按钮完成保存",
"saveFailed": "保存失败: {{error}}",
"hidePreview": "隐藏合并预览",
"showPreview": "显示合并预览",
"customConfig": "自定义配置(覆盖通用配置)",
"mergedPreview": "合并预览(只读)",
"mergedPreviewHint": "通用配置 + 自定义配置 = 最终配置"
"saveFailed": "保存失败: {{error}}"
},
"header": {
"viewOnGithub": "在 GitHub 上查看",
@@ -222,9 +214,7 @@
"requestGroup": "请求整流",
"responseGroup": "响应整流",
"thinkingSignature": "Thinking 签名整流",
"thinkingSignatureDescription": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次",
"thinkingBudget": "Thinking Budget 整流",
"thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024)时,自动将 thinking 规范为 enabled 并将 budget 设为 32000,同时在需要时将 max_tokens 设为 64000,然后重试一次"
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
},
"logConfig": {
"title": "日志管理",
@@ -546,10 +536,7 @@
"categoryOfficial": "官方",
"categoryCnOfficial": "开源官方",
"categoryAggregation": "聚合服务",
"categoryThirdParty": "第三方",
"commonConfigLoading": "通用配置正在加载中,请稍候再保存",
"commonConfigSyncFailed": "通用配置同步部分失败,请重试",
"saveCommonConfigFailed": "保存通用配置失败"
"categoryThirdParty": "第三方"
},
"endpointTest": {
"title": "请求地址管理",
@@ -614,12 +601,10 @@
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
"apiUrlLabel": "API 请求地址",
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"extractSuccessNeedSave": "已提取通用配置,点击保存按钮完成保存",
"saveFailed": "保存失败: {{error}}"
},
"geminiConfig": {
@@ -634,17 +619,14 @@
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"extractSuccessNeedSave": "已提取通用配置,点击保存按钮完成保存",
"saveFailed": "保存失败: {{error}}",
"extractedConfigInvalid": "提取的配置格式错误",
"invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)",
"invalidEnvFormat": "配置格式错误(请使用 KEY=VALUE 格式,每行一个)",
"commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}}",
"commonConfigInvalidValues": "通用配置片段的值必须是字符串",
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
"configMergeFailed": "配置合并失败: {{error}}",
"configReplaceFailed": "配置替换失败: {{error}}",
"forbiddenKeysWarning": "以下密钥已被自动过滤(禁止在通用配置中设置):{{keys}}"
"configReplaceFailed": "配置替换失败: {{error}}"
},
"opencode": {
"npmPackage": "接口格式",
+22 -477
View File
@@ -1,70 +1,28 @@
// 配置相关 API
import { invoke } from "@tauri-apps/api/core";
import type { Provider } from "@/types";
import { providersApi } from "./providers";
import {
detectContent,
type CommonConfigAppType,
} from "@/utils/commonConfigDetection";
export type AppType = CommonConfigAppType | "omo";
type SyncAppType = CommonConfigAppType;
export type AppType = "claude" | "codex" | "gemini" | "omo";
// ============================================================================
// 同步防抖管理
// ============================================================================
/** 同步结果类型 */
export interface SyncResult {
/** 成功更新的供应商数量 */
count: number;
/** 错误信息(如果有) */
error?: string;
/** 是否已排队等待执行(debounce 中) */
queued?: boolean;
/**
* 获取 Claude 通用配置片段(已废弃,使用 getCommonConfigSnippet
* @returns 通用配置片段(JSON 字符串),如果不存在则返回 null
* @deprecated 使用 getCommonConfigSnippet('claude') 替代
*/
export async function getClaudeCommonConfigSnippet(): Promise<string | null> {
return invoke<string | null>("get_claude_common_config_snippet");
}
/** 同步结果回调类型 */
export type SyncResultCallback = (result: SyncResult) => void;
// 每个 appType 的 debounce 定时器
const syncDebounceTimers: Record<
SyncAppType,
ReturnType<typeof setTimeout> | null
> = {
claude: null,
codex: null,
gemini: null,
};
// 每个 appType 的同步锁(防止并发)
const syncInFlight: Record<SyncAppType, boolean> = {
claude: false,
codex: false,
gemini: false,
};
// 每个 appType 的最新同步参数(用于 single-flight
const pendingSyncParams: Record<
SyncAppType,
{
oldSnippet: string;
newSnippet: string;
updateFn: (
settingsConfig: string,
oldSnippet: string,
newSnippet: string,
) => { updatedConfig: string; error?: string };
currentProviderId?: string;
onComplete?: SyncResultCallback;
} | null
> = {
claude: null,
codex: null,
gemini: null,
};
const SYNC_DEBOUNCE_MS = 500;
/**
* 设置 Claude 通用配置片段(已废弃,使用 setCommonConfigSnippet
* @param snippet - 通用配置片段(JSON 字符串)
* @throws 如果 JSON 格式无效
* @deprecated 使用 setCommonConfigSnippet('claude', snippet) 替代
*/
export async function setClaudeCommonConfigSnippet(
snippet: string,
): Promise<void> {
return invoke("set_claude_common_config_snippet", { snippet });
}
/**
* 获取通用配置片段(统一接口)
@@ -81,10 +39,7 @@ export async function getCommonConfigSnippet(
* 设置通用配置片段(统一接口)
* @param appType - 应用类型(claude/codex/gemini
* @param snippet - 通用配置片段(原始字符串)
* @throws 如果格式无效
* - Claude: 验证 JSON 格式
* - Gemini: 验证 ENV/JSON 格式,拒绝包含禁用键(GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL
* - Codex: TOML 格式,暂不验证
* @throws 如果格式无效Claude/Gemini 验证 JSONCodex 暂不验证)
*/
export async function setCommonConfigSnippet(
appType: AppType,
@@ -101,14 +56,14 @@ export async function setCommonConfigSnippet(
*
* @param appType - 应用类型(claude/codex/gemini
* @param options - 可选:提取来源
* @returns 提取的通用配置片段(Claude: JSON, Codex: TOML, Gemini: ENV
* @returns 提取的通用配置片段(JSON/TOML 字符串
*/
export type ExtractCommonConfigSnippetOptions = {
settingsConfig?: string;
};
export async function extractCommonConfigSnippet(
appType: CommonConfigAppType,
appType: Exclude<AppType, "omo">,
options?: ExtractCommonConfigSnippetOptions,
): Promise<string> {
const args: Record<string, unknown> = { appType };
@@ -120,413 +75,3 @@ export async function extractCommonConfigSnippet(
return invoke<string>("extract_common_config_snippet", args);
}
// ============================================================================
// 通用配置同步功能
// ============================================================================
/**
* 同步通用配置到所有启用了该配置的供应商(带 debounce + 同步锁)
*
* 当通用配置片段被修改时,需要更新所有启用了通用配置的供应商的 settingsConfig
* 使用 debounce + single-flight + in-flight lock 模式,避免快速编辑时的并发问题
*
* @param appType - 应用类型
* @param oldSnippet - 旧的通用配置片段
* @param newSnippet - 新的通用配置片段(空字符串表示清空/移除)
* @param updateFn - 更新函数,用于替换配置中的通用配置片段
* @param currentProviderId - 当前正在编辑的供应商 ID(跳过,因为已在编辑器中更新)
* @param onComplete - 同步完成后的回调,用于通知 UI 层
*/
export function syncCommonConfigToProviders(
appType: SyncAppType,
oldSnippet: string,
newSnippet: string,
updateFn: (
settingsConfig: string,
oldSnippet: string,
newSnippet: string,
) => { updatedConfig: string; error?: string },
currentProviderId?: string,
onComplete?: SyncResultCallback,
): void {
// 保存最新的同步参数(覆盖之前的,实现 single-flight
pendingSyncParams[appType] = {
oldSnippet,
newSnippet,
updateFn,
currentProviderId,
onComplete,
};
// 清除之前的定时器
if (syncDebounceTimers[appType]) {
clearTimeout(syncDebounceTimers[appType]!);
}
// 设置新的定时器,在 debounce 后执行实际同步
syncDebounceTimers[appType] = setTimeout(() => {
executeSyncWithLock(appType);
}, SYNC_DEBOUNCE_MS);
}
/**
* 带锁执行同步(防止并发)
*/
async function executeSyncWithLock(appType: SyncAppType): Promise<void> {
// 如果正在同步中,等待下一次调度
if (syncInFlight[appType]) {
// 已有同步在执行,参数已保存,等同步完成后会检查是否需要再次执行
return;
}
// 获取最新的参数
const params = pendingSyncParams[appType];
if (!params) {
return;
}
// 清除参数并设置锁
pendingSyncParams[appType] = null;
syncDebounceTimers[appType] = null;
syncInFlight[appType] = true;
try {
// 执行实际同步
const result = await doSyncCommonConfigToProviders(
appType,
params.oldSnippet,
params.newSnippet,
params.updateFn,
params.currentProviderId,
);
// 输出结果
if (result.error) {
console.warn(`[syncCommonConfig] ${appType} 同步失败: ${result.error}`);
}
// 通知回调
if (params.onComplete) {
params.onComplete(result);
}
} finally {
// 释放锁
syncInFlight[appType] = false;
// 检查是否有新的待执行参数(在同步期间又有新的请求)
if (pendingSyncParams[appType]) {
// 递归执行,处理新的请求
executeSyncWithLock(appType);
}
}
}
/**
* 实际执行同步的内部函数
* @returns 同步结果,包含更新数量和可能的错误信息
*/
async function doSyncCommonConfigToProviders(
appType: SyncAppType,
oldSnippet: string,
newSnippet: string,
updateFn: (
settingsConfig: string,
oldSnippet: string,
newSnippet: string,
) => { updatedConfig: string; error?: string },
currentProviderId?: string,
): Promise<SyncResult> {
try {
const providers = await providersApi.getAll(appType);
let updatedCount = 0;
const errors: string[] = [];
for (const [id, provider] of Object.entries(providers)) {
// 跳过当前正在编辑的供应商
if (id === currentProviderId) {
continue;
}
// 获取当前配置字符串(提前获取,用于内容检测)
const settingsConfigStr = getSettingsConfigString(provider, appType);
if (!settingsConfigStr) {
continue;
}
// 检查是否启用了通用配置
const metaByApp = provider.meta?.commonConfigEnabledByApp;
const resolvedMetaEnabled =
metaByApp?.[appType] ?? provider.meta?.commonConfigEnabled;
let isEnabled: boolean;
let needsMetaBackfill = false;
if (resolvedMetaEnabled !== undefined) {
// meta 有明确值,直接使用
isEnabled = resolvedMetaEnabled;
} else {
// meta 缺失,回退到内容检测(旧/新片段均可触发)
isEnabled = detectCommonConfigEnabledByContent(
appType,
settingsConfigStr,
oldSnippet,
newSnippet,
);
// 如果检测到启用,标记需要补写 meta
if (isEnabled) {
needsMetaBackfill = true;
}
}
if (!isEnabled) {
continue;
}
// 使用更新函数替换配置
const { updatedConfig, error } = updateFn(
settingsConfigStr,
oldSnippet,
newSnippet,
);
if (error) {
errors.push(`供应商 ${id}: ${error}`);
continue;
}
// 更新供应商配置
const updateResult = updateProviderSettingsConfig(
provider,
updatedConfig,
appType,
);
if (updateResult.error) {
errors.push(`供应商 ${id}: ${updateResult.error}`);
continue;
}
// 如果需要补写 meta,添加 commonConfigEnabledByApp
let providerToSave = updateResult.provider;
if (needsMetaBackfill) {
providerToSave = {
...providerToSave,
meta: {
...providerToSave.meta,
commonConfigEnabledByApp: {
...providerToSave.meta?.commonConfigEnabledByApp,
[appType]: true,
},
},
};
}
try {
await providersApi.update(providerToSave, appType);
updatedCount++;
} catch (updateError) {
errors.push(`供应商 ${id}: 保存失败 - ${String(updateError)}`);
}
}
if (errors.length > 0) {
return {
count: updatedCount,
error: `部分供应商更新失败: ${errors.join("; ")}`,
};
}
return { count: updatedCount };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
count: 0,
error: `同步失败: ${errorMessage}`,
};
}
}
function detectCommonConfigEnabledByContent(
appType: SyncAppType,
settingsConfigStr: string,
oldSnippet: string,
newSnippet: string,
): boolean {
const candidates = [oldSnippet, newSnippet].filter(
(snippet) => typeof snippet === "string" && snippet.trim(),
);
if (candidates.length === 0) return false;
// 使用 detectContent 获取完整结果(包含解析错误信息)
for (const snippet of candidates) {
const result = detectContent(appType, settingsConfigStr, snippet);
if (result.hasContent) {
return true;
}
// 如果解析失败,记录错误(便于排障)
if (result.parseError) {
console.warn(
`[detectCommonConfigEnabledByContent] Parse error for ${appType}: ${result.parseError}`,
);
}
}
return false;
}
/** 更新供应商配置的结果 */
interface UpdateProviderResult {
provider: Provider;
error?: string;
}
/**
* 从供应商获取配置字符串
*/
function getSettingsConfigString(
provider: Provider,
appType: SyncAppType,
): string | null {
// Runtime type may be string (JSON-serialized) despite Record<string, any> declaration
const config = provider.settingsConfig as Record<string, unknown> | string;
if (!config) return null;
switch (appType) {
case "claude":
// Claude: settingsConfig 直接是 JSON 对象
return typeof config === "string" ? config : JSON.stringify(config);
case "codex":
// Codex: settingsConfig.config 是 TOML 字符串
// 先校验 settingsConfig 是对象类型
if (typeof config === "string") {
// settingsConfig 是字符串,尝试解析为 JSON
try {
const parsed = JSON.parse(config);
if (
typeof parsed === "object" &&
parsed !== null &&
typeof parsed.config === "string"
) {
return parsed.config;
}
// JSON 对象但没有 string config 字段
// 可能是历史数据或异常保存的情况
return null;
} catch {
// JSON 解析失败,可能是纯 TOML 字符串
// 返回原字符串当 TOML 处理,而不是静默跳过
if (config.trim()) {
return config;
}
}
return null;
}
if (typeof config === "object" && config !== null) {
const codexConfig = (config as Record<string, unknown>).config;
return typeof codexConfig === "string" ? codexConfig : null;
}
return null;
case "gemini":
// Gemini: settingsConfig 是包含 env 字段的 JSON 对象
return typeof config === "string" ? config : JSON.stringify(config);
default:
return null;
}
}
/**
* 更新供应商的 settingsConfig
* @returns 更新结果,包含更新后的 provider 和可能的错误信息
*/
function updateProviderSettingsConfig(
provider: Provider,
updatedConfig: string,
appType: SyncAppType,
): UpdateProviderResult {
switch (appType) {
case "claude":
// Claude: 直接替换 settingsConfig
try {
const parsed = JSON.parse(updatedConfig);
if (typeof parsed !== "object" || parsed === null) {
return {
provider,
error: "解析后的配置不是有效的 JSON 对象",
};
}
return {
provider: {
...provider,
settingsConfig: parsed,
},
};
} catch (e) {
return {
provider,
error: `JSON 解析失败: ${e instanceof Error ? e.message : String(e)}`,
};
}
case "codex": {
// Codex: 更新 settingsConfig.config
// 先确保 settingsConfig 是对象类型
let baseConfig: Record<string, unknown>;
const currentConfig = provider.settingsConfig;
if (typeof currentConfig === "string") {
// 尝试解析字符串
try {
const parsed = JSON.parse(currentConfig);
baseConfig =
typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
baseConfig = {};
}
} else if (typeof currentConfig === "object" && currentConfig !== null) {
baseConfig = currentConfig as Record<string, unknown>;
} else {
baseConfig = {};
}
return {
provider: {
...provider,
settingsConfig: {
...baseConfig,
config: updatedConfig,
},
},
};
}
case "gemini":
// Gemini: 直接替换 settingsConfig
try {
const parsed = JSON.parse(updatedConfig);
if (typeof parsed !== "object" || parsed === null) {
return {
provider,
error: "解析后的配置不是有效的 JSON 对象",
};
}
return {
provider: {
...provider,
settingsConfig: parsed,
},
};
} catch (e) {
return {
provider,
error: `JSON 解析失败: ${e instanceof Error ? e.message : String(e)}`,
};
}
default:
return { provider };
}
}
-1
View File
@@ -155,7 +155,6 @@ export const settingsApi = {
export interface RectifierConfig {
enabled: boolean;
requestThinkingSignature: boolean;
requestThinkingBudget: boolean;
}
export interface LogConfig {
-11
View File
@@ -120,13 +120,6 @@ export interface ProviderProxyConfig {
proxyPassword?: string;
}
export type CommonConfigEnabledByApp = Partial<{
claude: boolean;
codex: boolean;
gemini: boolean;
opencode: boolean;
}>;
// 供应商元数据(字段名与后端一致,保持 snake_case
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
@@ -147,10 +140,6 @@ export interface ProviderMeta {
costMultiplier?: string;
// 供应商计费模式来源
pricingModelSource?: string;
// 是否启用通用配置片段(用于跨供应商保持勾选状态)
commonConfigEnabled?: boolean;
// 按应用记录通用配置启用状态(优先于 commonConfigEnabled
commonConfigEnabledByApp?: CommonConfigEnabledByApp;
// Claude API 格式(仅 Claude 供应商使用)
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
-245
View File
@@ -1,245 +0,0 @@
/**
*
*
* React/hooks lib/api 使
* lib/api hooks
*/
import { isPlainObject, isSubset } from "@/utils/configMerge";
import { safeParseToml } from "@/utils/tomlConfigMerge";
import {
parseGeminiCommonConfigSnippet,
GEMINI_COMMON_ENV_FORBIDDEN_KEYS,
} from "@/utils/providerConfigUtils";
// ============================================================================
// 类型定义
// ============================================================================
export type CommonConfigAppType = "claude" | "codex" | "gemini";
/**
*
*/
export interface ContentDetectionResult {
/** 是否包含通用配置内容 */
hasContent: boolean;
/** 检测失败原因(如果有) */
parseError?: string;
}
// ============================================================================
// Claude 内容检测
// ============================================================================
/**
* Claude
*/
export function hasClaudeContent(
configStr: string,
snippetStr: string,
): ContentDetectionResult {
try {
if (!snippetStr.trim()) {
return { hasContent: false };
}
const config = configStr ? JSON.parse(configStr) : {};
const snippet = JSON.parse(snippetStr);
if (!isPlainObject(snippet)) {
return { hasContent: false, parseError: "snippet is not a plain object" };
}
return { hasContent: isSubset(config, snippet) };
} catch (e) {
return {
hasContent: false,
parseError: e instanceof Error ? e.message : String(e),
};
}
}
// ============================================================================
// Codex 内容检测
// ============================================================================
/**
* Codex TOML
* TOML JSON wrapper { config: "TOML" }
*/
export function extractCodexConfigToml(configInput: string): {
toml: string;
parseError?: string;
} {
if (!configInput || !configInput.trim()) {
return { toml: "" };
}
// 尝试解析为 JSONwrapper 格式)
try {
const parsed = JSON.parse(configInput);
if (typeof parsed?.config === "string") {
return { toml: parsed.config };
}
// 是 JSON 对象但没有 string config 字段
// 这可能是未知 schema,返回原字符串当 TOML 处理
if (typeof parsed === "object" && parsed !== null) {
// 如果有 config 但不是 string,返回错误
if ("config" in parsed && typeof parsed.config !== "string") {
return {
toml: "",
parseError: `config field exists but is ${typeof parsed.config}, expected string`,
};
}
// 没有 config 字段的 JSON 对象,可能是纯 JSON 配置
// 返回空,因为无法当 TOML 处理
return { toml: "" };
}
} catch {
// JSON 解析失败,说明是直接的 TOML 字符串
}
return { toml: configInput };
}
/**
* Codex
*/
export function hasCodexContent(
configStr: string,
snippetStr: string,
): ContentDetectionResult {
if (!snippetStr.trim()) {
return { hasContent: false };
}
// 解析配置(可能是纯 TOML 或 JSON wrapper 格式)
const { toml: configToml, parseError: configError } =
extractCodexConfigToml(configStr);
if (configError) {
return { hasContent: false, parseError: configError };
}
const configParsed = safeParseToml(configToml);
const snippetParsed = safeParseToml(snippetStr);
if (configParsed.error || !configParsed.config) {
return {
hasContent: false,
parseError: configParsed.error || "failed to parse config TOML",
};
}
if (snippetParsed.error || !snippetParsed.config) {
return {
hasContent: false,
parseError: snippetParsed.error || "failed to parse snippet TOML",
};
}
// 使用 isSubset 检查 snippet 是否是 config 的子集
return { hasContent: isSubset(configParsed.config, snippetParsed.config) };
}
// ============================================================================
// Gemini 内容检测
// ============================================================================
/**
* Gemini
*/
export function hasGeminiContent(
configStr: string,
snippetStr: string,
): ContentDetectionResult {
try {
if (!snippetStr.trim()) {
return { hasContent: false };
}
const config = configStr ? JSON.parse(configStr) : {};
if (!isPlainObject(config)) {
return { hasContent: false, parseError: "config is not a plain object" };
}
const envValue = (config as Record<string, unknown>).env;
if (envValue !== undefined && !isPlainObject(envValue)) {
return {
hasContent: false,
parseError: "env field is not a plain object",
};
}
const env = (isPlainObject(envValue) ? envValue : {}) as Record<
string,
unknown
>;
const parseResult = parseGeminiCommonConfigSnippet(snippetStr, {
strictForbiddenKeys: false,
});
if (parseResult.error) {
return { hasContent: false, parseError: parseResult.error };
}
if (Object.keys(parseResult.env).length === 0) {
return { hasContent: false };
}
const hasContent = Object.entries(parseResult.env).every(([key, value]) => {
// 跳过禁用的键
if (GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as any)) {
return true;
}
const current = env[key];
return typeof current === "string" && current === value.trim();
});
return { hasContent };
} catch (e) {
return {
hasContent: false,
parseError: e instanceof Error ? e.message : String(e),
};
}
}
// ============================================================================
// 统一入口
// ============================================================================
/**
* appType
*
* lib/api/config.ts 使
*
*
* @param appType -
* @param configStr - settingsConfig
* @param snippetStr -
* @returns
*/
export function detectContent(
appType: CommonConfigAppType,
configStr: string,
snippetStr: string,
): ContentDetectionResult {
switch (appType) {
case "claude":
return hasClaudeContent(configStr, snippetStr);
case "codex":
return hasCodexContent(configStr, snippetStr);
case "gemini":
return hasGeminiContent(configStr, snippetStr);
default:
return { hasContent: false };
}
}
/**
* boolean
*
*
* 使 detectContent
*/
export function hasContentByAppType(
appType: CommonConfigAppType,
configStr: string,
snippetStr: string,
): boolean {
return detectContent(appType, configStr, snippetStr).hasContent;
}
-258
View File
@@ -1,258 +0,0 @@
/**
*
*
*
* - computeFinalConfig: 计算最终配置 +
* - extractDifference: live
*/
// ============================================================================
// 工具函数
// ============================================================================
/**
*
*/
export const isPlainObject = (
value: unknown,
): value is Record<string, unknown> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
/**
*
*/
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as T;
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T;
if (isPlainObject(obj)) {
const clonedObj = {} as Record<string, unknown>;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
clonedObj[key] = deepClone((obj as Record<string, unknown>)[key]);
}
}
return clonedObj as T;
}
return obj;
};
/**
*
*/
export const deepEqual = (a: unknown, b: unknown): boolean => {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a === null || b === null) return a === b;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, index) => deepEqual(item, b[index]));
}
if (isPlainObject(a) && isPlainObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => deepEqual(a[key], b[key]));
}
return false;
};
/**
* source target
* source target
*
*
*/
const IS_SUBSET_MAX_DEPTH = 20;
const IS_SUBSET_MAX_NODES = 10000;
export const isSubset = (
target: unknown,
source: unknown,
_depth: number = 0,
_nodeCounter: { count: number } = { count: 0 },
): boolean => {
// 超过最大深度或节点数限制,返回 false(保守判断)
if (_depth > IS_SUBSET_MAX_DEPTH) {
console.warn(
`[isSubset] Max depth (${IS_SUBSET_MAX_DEPTH}) exceeded, returning false`,
);
return false;
}
_nodeCounter.count++;
if (_nodeCounter.count > IS_SUBSET_MAX_NODES) {
console.warn(
`[isSubset] Max nodes (${IS_SUBSET_MAX_NODES}) exceeded, returning false`,
);
return false;
}
if (isPlainObject(source)) {
if (!isPlainObject(target)) return false;
return Object.entries(source).every(([key, value]) =>
isSubset(target[key], value, _depth + 1, _nodeCounter),
);
}
if (Array.isArray(source)) {
if (!Array.isArray(target) || target.length !== source.length) return false;
return source.every((item, index) =>
isSubset(target[index], item, _depth + 1, _nodeCounter),
);
}
return target === source;
};
// ============================================================================
// 配置合并函数
// ============================================================================
/**
* source target
*
*
* -
* - source target
* - source target
* - undefined/null config_merge.rs
*/
export const deepMerge = <T extends Record<string, unknown>>(
target: T,
source: T,
): T => {
const result = deepClone(target);
for (const key of Object.keys(source)) {
const sourceValue = source[key];
const targetValue = result[key];
// undefined 和 null 都不覆盖(与后端保持一致)
if (sourceValue === undefined || sourceValue === null) {
continue;
}
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
// 嵌套对象:递归合并
result[key as keyof T] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>,
) as T[keyof T];
} else {
// 其他情况(数组、原始值):source 覆盖
result[key as keyof T] = deepClone(sourceValue) as T[keyof T];
}
}
return result;
};
/**
*
*
* base
*
* @param customConfig - settings_config
* @param commonConfig -
* @param enabled -
* @returns
*/
export const computeFinalConfig = (
customConfig: Record<string, unknown>,
commonConfig: Record<string, unknown>,
enabled: boolean,
): Record<string, unknown> => {
const safeCustom = customConfig ?? {};
const safeCommon = commonConfig ?? {};
if (!enabled || Object.keys(safeCommon).length === 0) {
return deepClone(safeCustom);
}
// 通用配置作为 base,自定义配置覆盖
// 这样自定义配置的值会优先
return deepMerge(safeCommon, safeCustom);
};
// ============================================================================
// 差异提取函数
// ============================================================================
/**
*
*/
export interface ExtractDifferenceResult {
/** 自定义配置(与通用配置不同的部分) */
customConfig: Record<string, unknown>;
/** 是否检测到通用配置的键(用于判断是否应启用通用配置) */
hasCommonKeys: boolean;
}
/**
* live
*
*
* -
* -
* -
*
* @param liveConfig -
* @param commonConfig -
* @returns { customConfig, hasCommonKeys }
*/
export const extractDifference = (
liveConfig: Record<string, unknown>,
commonConfig: Record<string, unknown>,
): ExtractDifferenceResult => {
const customConfig: Record<string, unknown> = {};
let hasCommonKeys = false;
/**
*
*/
const extract = (
live: Record<string, unknown>,
common: Record<string, unknown>,
target: Record<string, unknown>,
): void => {
for (const [key, liveValue] of Object.entries(live)) {
const commonValue = common[key];
if (commonValue === undefined) {
// Case 1: 通用配置中不存在该键,完整保留到自定义配置
target[key] = deepClone(liveValue);
} else if (isPlainObject(liveValue) && isPlainObject(commonValue)) {
// Case 2: 嵌套对象,递归处理
const nested: Record<string, unknown> = {};
extract(
liveValue as Record<string, unknown>,
commonValue as Record<string, unknown>,
nested,
);
if (Object.keys(nested).length > 0) {
// 嵌套对象有差异,保留差异部分
target[key] = nested;
} else {
// 嵌套对象完全相同,标记有通用配置的键
hasCommonKeys = true;
}
} else if (!deepEqual(liveValue, commonValue)) {
// Case 3: 值不同,保留到自定义配置(用户覆盖)
target[key] = deepClone(liveValue);
} else {
// Case 4: 值完全相同,不保存到自定义配置(避免冗余)
hasCommonKeys = true;
}
}
};
extract(liveConfig, commonConfig, customConfig);
return { customConfig, hasCommonKeys };
};
+221 -298
View File
@@ -2,15 +2,86 @@
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { normalizeQuotes } from "@/utils/textNormalization";
import { isPlainObject } from "@/utils/configMerge";
// Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用)
export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
] as const;
export type GeminiForbiddenEnvKey =
(typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
const deepMerge = (
target: Record<string, any>,
source: Record<string, any>,
): Record<string, any> => {
Object.entries(source).forEach(([key, value]) => {
if (isPlainObject(value)) {
if (!isPlainObject(target[key])) {
target[key] = {};
}
deepMerge(target[key], value);
} else {
// 直接覆盖非对象字段(数组/基础类型)
target[key] = value;
}
});
return target;
};
const deepRemove = (
target: Record<string, any>,
source: Record<string, any>,
) => {
Object.entries(source).forEach(([key, value]) => {
if (!(key in target)) return;
if (isPlainObject(value) && isPlainObject(target[key])) {
// 只移除完全匹配的嵌套属性
deepRemove(target[key], value);
if (Object.keys(target[key]).length === 0) {
delete target[key];
}
} else if (isSubset(target[key], value)) {
// 只有当值完全匹配时才删除
delete target[key];
}
});
};
const isSubset = (target: any, source: any): boolean => {
if (isPlainObject(source)) {
if (!isPlainObject(target)) return false;
return Object.entries(source).every(([key, value]) =>
isSubset(target[key], value),
);
}
if (Array.isArray(source)) {
if (!Array.isArray(target) || target.length !== source.length) return false;
return source.every((item, index) => isSubset(target[index], item));
}
return target === source;
};
// 深拷贝函数
const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as T;
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
if (obj instanceof Object) {
const clonedObj = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
return obj;
};
export interface UpdateCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 验证JSON配置格式
export const validateJsonConfig = (
@@ -31,6 +102,69 @@ export const validateJsonConfig = (
}
};
// 将通用配置片段写入/移除 settingsConfig
export const updateCommonConfigSnippet = (
jsonString: string,
snippetString: string,
enabled: boolean,
): UpdateCommonConfigResult => {
let config: Record<string, any>;
try {
config = jsonString ? JSON.parse(jsonString) : {};
} catch (err) {
return {
updatedConfig: jsonString,
error: "配置 JSON 解析失败,无法写入通用配置",
};
}
if (!snippetString.trim()) {
return {
updatedConfig: JSON.stringify(config, null, 2),
};
}
// 使用统一的验证函数
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
if (snippetError) {
return {
updatedConfig: JSON.stringify(config, null, 2),
error: snippetError,
};
}
const snippet = JSON.parse(snippetString) as Record<string, any>;
if (enabled) {
const merged = deepMerge(deepClone(config), snippet);
return {
updatedConfig: JSON.stringify(merged, null, 2),
};
}
const cloned = deepClone(config);
deepRemove(cloned, snippet);
return {
updatedConfig: JSON.stringify(cloned, null, 2),
};
};
// 检查当前配置是否已包含通用配置片段
export const hasCommonConfigSnippet = (
jsonString: string,
snippetString: string,
): boolean => {
try {
if (!snippetString.trim()) return false;
const config = jsonString ? JSON.parse(jsonString) : {};
const snippet = JSON.parse(snippetString);
if (!isPlainObject(snippet)) return false;
return isSubset(config, snippet);
} catch (err) {
return false;
}
};
// 读取配置中的 API Key(支持 Claude, Codex, Gemini
export const getApiKeyFromConfig = (
jsonString: string,
@@ -195,6 +329,85 @@ export const setApiKeyInConfig = (
}
};
// ========== TOML Config Utilities ==========
export interface UpdateTomlCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 保存之前的通用配置片段,用于替换操作
let previousCommonSnippet = "";
// 将通用配置片段写入/移除 TOML 配置
export const updateTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
enabled: boolean,
): UpdateTomlCommonConfigResult => {
if (!snippetString.trim()) {
// 如果片段为空,直接返回原始配置
return {
updatedConfig: tomlString,
};
}
if (enabled) {
// 添加通用配置
// 先移除旧的通用配置(如果有)
let updatedConfig = tomlString;
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
updatedConfig = tomlString.replace(previousCommonSnippet, "");
}
// 在文件末尾添加新的通用配置
// 确保有适当的换行
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
updatedConfig =
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
// 保存当前通用配置片段
previousCommonSnippet = snippetString;
return {
updatedConfig: updatedConfig.trim() + "\n",
};
} else {
// 移除通用配置
if (tomlString.includes(snippetString)) {
const updatedConfig = tomlString.replace(snippetString, "");
// 清理多余的空行
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
// 清空保存的状态
previousCommonSnippet = "";
return {
updatedConfig: cleaned ? cleaned + "\n" : "",
};
}
return {
updatedConfig: tomlString,
};
}
};
// 检查 TOML 配置是否已包含通用配置片段
export const hasTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
): boolean => {
if (!snippetString.trim()) return false;
// 简单检查配置是否包含片段内容
// 去除空白字符后比较,避免格式差异影响
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
return normalizeWhitespace(tomlString).includes(
normalizeWhitespace(snippetString),
);
};
// ========== Codex base_url utils ==========
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
@@ -317,293 +530,3 @@ export const setCodexModelName = (
const lines = normalizedText.split("\n");
return `${replacementLine}\n${lines.join("\n")}`;
};
// ============================================================================
// Gemini Common Config Parsing Utilities
// ============================================================================
/**
* Error codes for Gemini common config parsing.
* These codes are used for consistent error handling and i18n mapping.
*/
export const GEMINI_CONFIG_ERROR_CODES = {
NOT_OBJECT: "NOT_OBJECT",
ENV_NOT_OBJECT: "ENV_NOT_OBJECT",
VALUE_NOT_STRING: "VALUE_NOT_STRING",
FORBIDDEN_KEYS: "FORBIDDEN_KEYS",
} as const;
export type GeminiConfigErrorCode =
(typeof GEMINI_CONFIG_ERROR_CODES)[keyof typeof GEMINI_CONFIG_ERROR_CODES];
/**
* Structured error info for Gemini common config parsing.
* Replaces fragile string prefix matching with type-safe error handling.
*/
export interface GeminiConfigErrorInfo {
/** Error code for programmatic handling */
code: GeminiConfigErrorCode;
/** Human-readable error message */
message: string;
/** For FORBIDDEN_KEYS: the list of forbidden keys found */
keys?: string[];
/** For VALUE_NOT_STRING: the key with invalid value */
key?: string;
/** For VALUE_NOT_STRING: the actual type of the value */
valueType?: string;
}
/**
* Result of parsing Gemini common config snippet
*/
export interface GeminiCommonConfigParseResult {
/** Parsed env key-value pairs (empty if invalid) */
env: Record<string, string>;
/**
* Error message if parsing/validation failed.
* @deprecated Use errorInfo for type-safe error handling
*/
error?: string;
/** Structured error info for type-safe handling */
errorInfo?: GeminiConfigErrorInfo;
/** Warning message (non-fatal, config still usable) */
warning?: string;
/** Structured warning info */
warningInfo?: GeminiConfigErrorInfo;
}
/**
* Parse Gemini common config snippet with full validation.
*
* Supports three formats:
* - ENV format: KEY=VALUE lines (one per line, # for comments)
* - Flat JSON: {"KEY": "VALUE", ...}
* - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
*
* Validation rules:
* - Forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY) are rejected
* - Non-string values are rejected
* - Empty string values are filtered out
* - Arrays and non-plain objects are rejected
*
* @param snippet - The common config snippet string
* @param options - Optional configuration
* @returns Parse result with env, error, and warning
*/
export function parseGeminiCommonConfigSnippet(
snippet: string,
options?: {
/** If true, reject forbidden keys with error; otherwise filter them with warning */
strictForbiddenKeys?: boolean;
},
): GeminiCommonConfigParseResult {
const trimmed = snippet.trim();
if (!trimmed) {
return { env: {} };
}
const strictForbiddenKeys = options?.strictForbiddenKeys ?? true;
let rawEnv: Record<string, unknown> = {};
let isJson = false;
// Try JSON first
try {
const parsed = JSON.parse(trimmed);
// Must be a plain object (not array, null, etc.)
if (!isPlainObject(parsed)) {
const errorInfo: GeminiConfigErrorInfo = {
code: GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT,
message: "must be a JSON object, not array or primitive",
};
return {
env: {},
error: `${errorInfo.code}: ${errorInfo.message}`,
errorInfo,
};
}
isJson = true;
// Check if wrapped format {"env": {...}}
if ("env" in parsed) {
const envField = parsed.env;
if (!isPlainObject(envField)) {
const errorInfo: GeminiConfigErrorInfo = {
code: GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT,
message: "'env' field must be a plain object",
};
return {
env: {},
error: `${errorInfo.code}: ${errorInfo.message}`,
errorInfo,
};
}
rawEnv = envField as Record<string, unknown>;
} else {
// Flat format
rawEnv = parsed as Record<string, unknown>;
}
} catch {
// Not JSON, parse as ENV format (KEY=VALUE lines)
isJson = false;
for (const line of trimmed.split("\n")) {
const lineTrimmed = line.trim();
if (!lineTrimmed || lineTrimmed.startsWith("#")) continue;
const equalIndex = lineTrimmed.indexOf("=");
if (equalIndex > 0) {
const key = lineTrimmed.substring(0, equalIndex).trim();
// Strip surrounding quotes (single or double) from value
// e.g., KEY="value" or KEY='value' -> value
const rawValue = lineTrimmed.substring(equalIndex + 1).trim();
const value = rawValue.replace(/^["'](.*)["']$/, "$1");
if (key) {
rawEnv[key] = value;
}
}
}
}
// Validate and filter entries
const env: Record<string, string> = {};
const forbiddenKeysFound: string[] = [];
for (const [key, value] of Object.entries(rawEnv)) {
// Check forbidden keys
if (
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey)
) {
forbiddenKeysFound.push(key);
continue;
}
// Must be string
if (typeof value !== "string") {
if (isJson) {
const errorInfo: GeminiConfigErrorInfo = {
code: GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING,
message: `value for '${key}' must be a string, got ${typeof value}`,
key,
valueType: typeof value,
};
return {
env: {},
error: `${errorInfo.code}: ${errorInfo.message}`,
errorInfo,
};
}
// For ENV format, skip non-strings silently (shouldn't happen)
continue;
}
// Filter empty strings
const trimmedValue = value.trim();
if (!trimmedValue) {
continue;
}
env[key] = trimmedValue;
}
// Handle forbidden keys
if (forbiddenKeysFound.length > 0) {
const errorInfo: GeminiConfigErrorInfo = {
code: GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS,
message: forbiddenKeysFound.join(", "),
keys: forbiddenKeysFound,
};
const msg = `${errorInfo.code}: ${errorInfo.message}`;
if (strictForbiddenKeys) {
return { env: {}, error: msg, errorInfo };
}
// Return as warning instead of error
return {
env,
warning: msg,
warningInfo: errorInfo,
};
}
return { env };
}
/**
* Map Gemini common config error/warning info to i18n-friendly message.
* Prefers structured errorInfo, falls back to string parsing for backward compatibility.
*
* @param info - Structured error info or raw warning string
* @param t - The i18n translation function
* @returns Translated message
*/
export function mapGeminiErrorToI18n(
info: GeminiConfigErrorInfo | string,
t: (
key: string,
options?: { keys?: string; key?: string; defaultValue?: string },
) => string,
): string {
// Handle structured error info (preferred)
if (typeof info === "object") {
switch (info.code) {
case GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS:
return t("geminiConfig.forbiddenKeysWarning", {
keys: info.keys?.join(", ") ?? info.message,
});
case GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING:
return t("geminiConfig.commonConfigInvalidValues", {
key: info.key,
defaultValue: info.message,
});
case GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT:
case GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT:
return t("geminiConfig.invalidEnvFormat", {
defaultValue: info.message,
});
default:
return info.message;
}
}
// Backward compatibility: parse string format
if (info.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
const keys = info.replace(
`${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: `,
"",
);
return t("geminiConfig.forbiddenKeysWarning", { keys });
}
if (info.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)) {
return t("geminiConfig.commonConfigInvalidValues", {
defaultValue: "Invalid value type",
});
}
if (
info.startsWith(GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT) ||
info.startsWith(GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT)
) {
return t("geminiConfig.invalidEnvFormat", {
defaultValue: "Invalid format",
});
}
// Unknown format, return as-is
return info;
}
/**
* Map Gemini common config warning to i18n-friendly message.
* @deprecated Use mapGeminiErrorToI18n with structured errorInfo/warningInfo instead
*
* @param warning - The raw warning string from parseGeminiCommonConfigSnippet
* @param t - The i18n translation function
* @returns Translated warning message
*/
export function mapGeminiWarningToI18n(
warning: string,
t: (
key: string,
options?: { keys?: string; defaultValue?: string },
) => string,
): string {
return mapGeminiErrorToI18n(warning, t);
}
-364
View File
@@ -1,364 +0,0 @@
/**
* TOML
*
* Codex TOML
* TOML 使/
*/
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { normalizeTomlText } from "@/utils/textNormalization";
import {
computeFinalConfig,
extractDifference,
isPlainObject,
} from "./configMerge";
// ============================================================================
// TOML 解析/序列化工具
// ============================================================================
/**
* TOML
*/
export const safeParseToml = (
tomlString: string,
): { config: Record<string, unknown> | null; error: string | null } => {
try {
if (!tomlString.trim()) {
return { config: {}, error: null };
}
const normalized = normalizeTomlText(tomlString);
const parsed = parseToml(normalized);
if (!isPlainObject(parsed)) {
return { config: null, error: "TOML 解析结果不是对象" };
}
return { config: parsed as Record<string, unknown>, error: null };
} catch (e) {
return {
config: null,
error: e instanceof Error ? e.message : "TOML 解析失败",
};
}
};
/**
* TOML
* section [model_providers] [model_providers.packycode]
*/
export const safeStringifyToml = (
config: Record<string, unknown>,
): { toml: string; error: string | null } => {
try {
if (Object.keys(config).length === 0) {
return { toml: "", error: null };
}
let toml = stringifyToml(config);
// 移除冗余的空父级 section 头
// 例如:[model_providers]\n[model_providers.packycode] -> [model_providers.packycode]
toml = removeEmptyParentSections(toml);
return { toml, error: null };
} catch (e) {
return {
toml: "",
error: e instanceof Error ? e.message : "TOML 序列化失败",
};
}
};
/**
* section
* section section section
*/
function removeEmptyParentSections(toml: string): string {
const lines = toml.split("\n");
const result: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// 检查是否是 section 头
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
const currentSection = sectionMatch[1];
// 查找下一个非空行
let nextIdx = i + 1;
while (nextIdx < lines.length && !lines[nextIdx].trim()) {
nextIdx++;
}
// 如果下一个非空行也是一个 section 头,检查是否是子 section
if (nextIdx < lines.length) {
const nextLine = lines[nextIdx].trim();
const nextSectionMatch = nextLine.match(/^\[([^\]]+)\]$/);
if (nextSectionMatch) {
const nextSection = nextSectionMatch[1];
// 如果下一个 section 是当前 section 的子 section,跳过当前空的父 section
if (nextSection.startsWith(currentSection + ".")) {
// 跳过当前行和之间的空行
continue;
}
}
}
}
result.push(line);
}
return result.join("\n");
}
// ============================================================================
// 键顺序重排工具
// ============================================================================
/**
*
*
* TOML model_provider, model
* model_reasoning_effort
*
* @param merged -
* @param custom -
* @param common -
* @returns
*/
function reorderMergedKeys(
merged: Record<string, unknown>,
custom: Record<string, unknown>,
common: Record<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
// 1. 先添加自定义配置中的键(保持自定义配置中的顺序)
for (const key of Object.keys(custom)) {
if (key in merged) {
const mergedValue = merged[key];
const customValue = custom[key];
const commonValue = common[key];
// 如果是嵌套对象,递归重排
if (
isPlainObject(mergedValue) &&
isPlainObject(customValue) &&
isPlainObject(commonValue)
) {
result[key] = reorderMergedKeys(
mergedValue as Record<string, unknown>,
customValue as Record<string, unknown>,
commonValue as Record<string, unknown>,
);
} else if (isPlainObject(mergedValue) && isPlainObject(customValue)) {
// 通用配置中没有这个嵌套对象,直接使用合并后的值
result[key] = mergedValue;
} else {
result[key] = mergedValue;
}
}
}
// 2. 再添加通用配置独有的键(不在自定义配置中)
for (const key of Object.keys(common)) {
if (!(key in custom) && key in merged) {
result[key] = merged[key];
}
}
return result;
}
// ============================================================================
// TOML 配置合并函数
// ============================================================================
/**
* TOML
*
* @param customToml - TOML
* @param commonToml - TOML
* @param enabled -
* @returns TOML
*/
export const computeFinalTomlConfig = (
customToml: string,
commonToml: string,
enabled: boolean,
): { finalConfig: string; error?: string } => {
// 如果未启用或通用配置为空,直接返回自定义配置
if (!enabled || !commonToml.trim()) {
return { finalConfig: customToml };
}
// 解析自定义配置
const customResult = safeParseToml(customToml);
if (customResult.error) {
return {
finalConfig: customToml,
error: `自定义配置解析失败: ${customResult.error}`,
};
}
// 解析通用配置
const commonResult = safeParseToml(commonToml);
if (commonResult.error) {
return {
finalConfig: customToml,
error: `通用配置解析失败: ${commonResult.error}`,
};
}
// 使用通用合并函数
const merged = computeFinalConfig(
customResult.config!,
commonResult.config!,
true, // enabled 已在上面检查
);
// 重排键顺序:自定义配置的键在前,通用配置独有的键在后
// 这样序列化后 TOML 输出中用户自定义的键(如 model_provider, model)会在顶部
const reordered = reorderMergedKeys(
merged,
customResult.config!,
commonResult.config!,
);
// 序列化回 TOML
const stringifyResult = safeStringifyToml(reordered);
if (stringifyResult.error) {
return {
finalConfig: customToml,
error: `TOML 序列化失败: ${stringifyResult.error}`,
};
}
return { finalConfig: stringifyResult.toml };
};
// ============================================================================
// TOML 差异提取函数
// ============================================================================
/**
* TOML
*/
export interface ExtractTomlDifferenceResult {
/** 自定义 TOML 配置字符串(与通用配置不同的部分) */
customToml: string;
/** 是否检测到通用配置的键 */
hasCommonKeys: boolean;
/** 错误信息 */
error?: string;
}
/**
* live TOML
*
* @param liveToml - TOML
* @param commonToml - TOML
* @returns { customToml, hasCommonKeys, error }
*/
export const extractTomlDifference = (
liveToml: string,
commonToml: string,
): ExtractTomlDifferenceResult => {
// 如果通用配置为空,live 配置就是自定义配置
if (!commonToml.trim()) {
return { customToml: liveToml, hasCommonKeys: false };
}
// 解析 live 配置
const liveResult = safeParseToml(liveToml);
if (liveResult.error) {
return {
customToml: liveToml,
hasCommonKeys: false,
error: `Live 配置解析失败: ${liveResult.error}`,
};
}
// 解析通用配置
const commonResult = safeParseToml(commonToml);
if (commonResult.error) {
return {
customToml: liveToml,
hasCommonKeys: false,
error: `通用配置解析失败: ${commonResult.error}`,
};
}
// 使用通用差异提取函数
const diffResult = extractDifference(
liveResult.config!,
commonResult.config!,
);
// 序列化回 TOML
const stringifyResult = safeStringifyToml(diffResult.customConfig);
if (stringifyResult.error) {
return {
customToml: liveToml,
hasCommonKeys: false,
error: `TOML 序列化失败: ${stringifyResult.error}`,
};
}
return {
customToml: stringifyResult.toml,
hasCommonKeys: diffResult.hasCommonKeys,
};
};
/**
* TOML
* 使
*/
export const hasTomlCommonConfig = (
tomlString: string,
commonTomlString: string,
): boolean => {
if (!commonTomlString.trim()) return false;
const liveResult = safeParseToml(tomlString);
const commonResult = safeParseToml(commonTomlString);
if (liveResult.error || commonResult.error) {
return false;
}
// 检查 common 中的所有键是否存在于 live 中且值相等
const checkSubset = (
live: Record<string, unknown>,
common: Record<string, unknown>,
): boolean => {
for (const [key, commonValue] of Object.entries(common)) {
const liveValue = live[key];
if (liveValue === undefined) {
return false;
}
if (isPlainObject(commonValue) && isPlainObject(liveValue)) {
if (
!checkSubset(
liveValue as Record<string, unknown>,
commonValue as Record<string, unknown>,
)
) {
return false;
}
} else if (Array.isArray(commonValue) && Array.isArray(liveValue)) {
if (JSON.stringify(commonValue) !== JSON.stringify(liveValue)) {
return false;
}
} else if (liveValue !== commonValue) {
return false;
}
}
return true;
};
return checkSubset(liveResult.config!, commonResult.config!);
};
+13 -14
View File
@@ -450,18 +450,17 @@ describe("useProviderActions", () => {
expect(result.current.isLoading).toBe(true);
});
it("clears loading flag when all mutations idle", () => {
addProviderMutation.isPending = false;
updateProviderMutation.isPending = false;
deleteProviderMutation.isPending = false;
switchProviderMutation.isPending = false;
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
expect(result.current.isLoading).toBe(false);
});
});
it("clears loading flag when all mutations idle", () => {
addProviderMutation.isPending = false;
updateProviderMutation.isPending = false;
deleteProviderMutation.isPending = false;
switchProviderMutation.isPending = false;
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
expect(result.current.isLoading).toBe(false);
});