Compare commits

..

2 Commits

Author SHA1 Message Date
YoVinchen 07d00e726e fix(proxy): thinking rectifiers 2026-02-12 22:17:52 +08:00
YoVinchen 579df5ad99 feat(proxy): align thinking rectifiers and resolve clippy warnings
- add thinking budget rectifier flow with single retry on anthropic budget errors

- align thinking signature rectification behavior with adaptive-safe handling

- expose requestThinkingBudget in settings/ui/i18n and default rectifier config to disabled

- fix clippy warnings in model_mapper format args and RectifierConfig default derive
2026-02-12 00:46:42 +08:00
50 changed files with 1869 additions and 6559 deletions
-1
View File
@@ -57,7 +57,6 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
-39
View File
@@ -59,9 +59,6 @@ importers:
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1051,19 +1048,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -3906,29 +3890,6 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
aria-hidden: 1.2.6
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
-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);
}
}
+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,
+15 -110
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,
@@ -311,22 +286,10 @@ impl OmoService {
let obj = Self::read_jsonc_object(&actual_path)?;
Ok(Self::build_local_file_data_from_obj(
&obj,
actual_path.to_string_lossy().to_string(),
last_modified,
))
}
fn build_local_file_data_from_obj(
obj: &Map<String, Value>,
file_path: String,
last_modified: Option<String>,
) -> OmoLocalFileData {
let agents = obj.get("agents").cloned();
let categories = obj.get("categories").cloned();
let other = Self::extract_other_fields(obj);
let other = Self::extract_other_fields(&obj);
let other_fields = if other.is_empty() {
None
} else {
@@ -334,17 +297,16 @@ impl OmoService {
};
let mut global = OmoGlobalConfig::default();
Self::merge_global_from_obj(obj, &mut global);
global.other_fields = other_fields.clone();
Self::merge_global_from_obj(&obj, &mut global);
OmoLocalFileData {
Ok(OmoLocalFileData {
agents,
categories,
other_fields,
global,
file_path,
file_path: actual_path.to_string_lossy().to_string(),
last_modified,
}
})
}
fn strip_jsonc_comments(input: &str) -> String {
@@ -438,7 +400,7 @@ mod tests {
..Default::default()
};
let agents = Some(serde_json::json!({
"sisyphus": { "model": "claude-opus-4-5" }
"Sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
@@ -449,7 +411,7 @@ mod tests {
assert_eq!(obj["$schema"], "https://example.com/schema.json");
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
assert!(obj.contains_key("agents"));
assert_eq!(obj["agents"]["sisyphus"]["model"], "claude-opus-4-5");
assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5");
}
#[test]
@@ -460,7 +422,7 @@ mod tests {
..Default::default()
};
let agents = Some(serde_json::json!({
"sisyphus": { "model": "claude-opus-4-5" }
"Sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
@@ -472,61 +434,4 @@ mod tests {
assert!(!obj.contains_key("disabled_agents"));
assert!(obj.contains_key("agents"));
}
#[test]
fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() {
let obj = serde_json::json!({
"$schema": "https://example.com/schema.json",
"disabled_agents": ["oracle"],
"agents": {
"sisyphus": { "model": "claude-opus-4-6" }
},
"categories": {
"code": { "model": "gpt-5.3" }
},
"custom_top_level": {
"enabled": true
}
});
let obj_map = obj.as_object().unwrap().clone();
let data = OmoService::build_local_file_data_from_obj(
&obj_map,
"/tmp/oh-my-opencode.jsonc".to_string(),
None,
);
assert_eq!(
data.global.schema_url.as_deref(),
Some("https://example.com/schema.json")
);
assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]);
assert_eq!(
data.other_fields,
Some(serde_json::json!({
"custom_top_level": { "enabled": true }
}))
);
assert_eq!(data.global.other_fields, data.other_fields);
}
#[test]
fn test_merge_config_ignores_non_object_other_fields() {
let global = OmoGlobalConfig {
other_fields: Some(serde_json::json!(["global_non_object"])),
..Default::default()
};
let agents = None;
let categories = None;
let other_fields = Some(serde_json::json!("profile_non_object"));
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert!(!obj.contains_key("0"));
assert!(!obj.contains_key("global_non_object"));
assert!(!obj.contains_key("profile_non_object"));
}
}
+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">
+89 -267
View File
@@ -12,19 +12,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Plus,
Trash2,
@@ -34,10 +21,6 @@ import {
Settings,
FolderInput,
Loader2,
HelpCircle,
Check,
ChevronsUpDown,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
@@ -60,13 +43,6 @@ const ADVANCED_PLACEHOLDER = `{
interface OmoFormFieldsProps {
modelOptions: Array<{ value: string; label: string }>;
modelVariantsMap?: Record<string, string[]>;
presetMetaMap?: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>;
agents: Record<string, Record<string, unknown>>;
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
categories: Record<string, Record<string, unknown>>;
@@ -77,149 +53,19 @@ interface OmoFormFieldsProps {
onOtherFieldsStrChange: (value: string) => void;
}
export type CustomModelItem = {
key: string;
model: string;
sourceKey?: string;
};
type CustomModelItem = { key: string; model: string };
type BuiltinModelDef = Pick<
OmoAgentDef | OmoCategoryDef,
"key" | "display" | "descKey" | "recommended" | "tooltipKey"
"key" | "display" | "descZh" | "descEn" | "recommended"
>;
type ModelOption = { value: string; label: string };
function DeferredKeyInput({
value,
onCommit,
placeholder,
className,
}: {
value: string;
onCommit: (value: string) => void;
placeholder?: string;
className?: string;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
return (
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft !== value) {
onCommit(draft);
}
}}
placeholder={placeholder}
className={className}
/>
);
}
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__";
const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__";
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
function ModelCombobox({
value,
options,
recommended,
onChange,
}: {
value: string;
options: ModelOption[];
recommended?: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const selectedLabel = options.find((o) => o.value === value)?.label;
const selectModelText = t("omo.selectModel", {
defaultValue: "Select configured model",
});
const placeholderText = recommended
? `${selectModelText} (${t("omo.recommendedHint", { model: recommended, defaultValue: "Recommended: {{model}}" })})`
: selectModelText;
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
className="flex flex-1 h-8 items-center justify-between whitespace-nowrap rounded-md border border-border-default bg-background px-3 py-1 text-sm shadow-sm ring-offset-background focus:outline-none focus-visible:outline-none focus:border-border-default focus-visible:border-border-default focus:ring-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className={cn("truncate", !value && "text-muted-foreground")}>
{selectedLabel || placeholderText}
</span>
<span className="flex items-center shrink-0 ml-1 gap-0.5">
{value && (
<X
className="h-3.5 w-3.5 opacity-50 hover:opacity-100 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
/>
)}
<ChevronsUpDown className="h-3.5 w-3.5 opacity-50" />
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
sideOffset={6}
avoidCollisions={true}
collisionPadding={8}
className="z-[1000] w-[var(--radix-popover-trigger-width)] p-0 border-border-default"
>
<Command>
<CommandInput
placeholder={t("omo.searchModel", {
defaultValue: "Search model...",
})}
/>
<CommandList>
<CommandEmpty>
{t("omo.noEnabledModels", {
defaultValue: "No configured models",
})}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label]}
onSelect={() => {
onChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__";
function getAdvancedStr(config: Record<string, unknown> | undefined): string {
if (!config) return "";
@@ -240,58 +86,24 @@ function collectCustomModels(
customs.push({
key: k,
model: ((v as Record<string, unknown>).model as string) || "",
sourceKey: k,
});
}
}
return customs;
}
export function mergeCustomModelsIntoStore(
function mergeCustomModelsIntoStore(
store: Record<string, Record<string, unknown>>,
builtinKeys: Set<string>,
customs: CustomModelItem[],
modelVariantsMap: Record<string, string[]>,
): Record<string, Record<string, unknown>> {
const updated: Record<string, Record<string, unknown>> = {};
for (const [key, value] of Object.entries(store)) {
if (builtinKeys.has(key)) {
updated[key] = { ...value };
}
const updated = { ...store };
for (const key of Object.keys(updated)) {
if (!builtinKeys.has(key)) delete updated[key];
}
for (const custom of customs) {
const targetKey = custom.key.trim();
if (!targetKey) continue;
const sourceKey = (custom.sourceKey || targetKey).trim();
const sourceEntry = store[sourceKey] ?? store[targetKey];
const nextEntry = {
...(updated[targetKey] || {}),
...(sourceEntry || {}),
};
if (custom.model.trim()) {
nextEntry.model = custom.model;
const currentVariant =
typeof nextEntry.variant === "string" ? nextEntry.variant : "";
if (currentVariant) {
const validVariants = modelVariantsMap[custom.model] || [];
if (!validVariants.includes(currentVariant)) {
delete nextEntry.variant;
}
}
updated[targetKey] = nextEntry;
continue;
}
delete nextEntry.model;
delete nextEntry.variant;
if (Object.keys(nextEntry).length > 0) {
updated[targetKey] = nextEntry;
} else {
delete updated[targetKey];
if (custom.key.trim()) {
updated[custom.key] = { ...updated[custom.key], model: custom.model };
}
}
return updated;
@@ -300,7 +112,6 @@ export function mergeCustomModelsIntoStore(
export function OmoFormFields({
modelOptions,
modelVariantsMap = {},
presetMetaMap: _presetMetaMap = {},
agents,
onAgentsChange,
categories,
@@ -308,7 +119,8 @@ export function OmoFormFields({
otherFieldsStr,
onOtherFieldsStrChange,
}: OmoFormFieldsProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const isZh = i18n.language?.startsWith("zh");
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
@@ -347,29 +159,19 @@ export function OmoFormFields({
const syncCustomAgents = useCallback(
(customs: CustomModelItem[]) => {
onAgentsChange(
mergeCustomModelsIntoStore(
agents,
BUILTIN_AGENT_KEYS,
customs,
modelVariantsMap,
),
mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs),
);
},
[agents, onAgentsChange, modelVariantsMap],
[agents, onAgentsChange],
);
const syncCustomCategories = useCallback(
(customs: CustomModelItem[]) => {
onCategoriesChange(
mergeCustomModelsIntoStore(
categories,
BUILTIN_CATEGORY_KEYS,
customs,
modelVariantsMap,
),
mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs),
);
},
[categories, onCategoriesChange, modelVariantsMap],
[categories, onCategoriesChange],
);
const buildEffectiveModelOptions = useCallback(
@@ -410,16 +212,43 @@ export function OmoFormFields({
const renderModelSelect = (
currentModel: string,
onChange: (value: string) => void,
recommended?: string,
placeholder?: string,
) => {
const options = buildEffectiveModelOptions(currentModel);
return (
<ModelCombobox
value={currentModel}
options={options}
recommended={recommended}
onChange={onChange}
/>
<Select
value={currentModel || EMPTY_MODEL_VALUE}
onValueChange={(value) =>
onChange(value === EMPTY_MODEL_VALUE ? "" : value)
}
>
<SelectTrigger className="flex-1 h-8 text-sm">
<SelectValue
placeholder={
placeholder ||
t("omo.selectEnabledModel", {
defaultValue: "Select enabled model",
})
}
/>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={EMPTY_MODEL_VALUE}>
{t("omo.clearWrapped", { defaultValue: "(Clear)" })}
</SelectItem>
{options.length === 0 ? (
<SelectItem value={UNAVAILABLE_MODEL_VALUE} disabled>
{t("omo.noEnabledModels", { defaultValue: "No enabled models" })}
</SelectItem>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
@@ -439,21 +268,11 @@ export function OmoFormFields({
currentVariant: string,
onChange: (value: string) => void,
) => {
const hasModel = Boolean(currentModel);
const modelVariantKeys = hasModel
? modelVariantsMap[currentModel] || []
: [];
const hasVariants = modelVariantKeys.length > 0;
const shouldShow = hasModel && (hasVariants || Boolean(currentVariant));
if (!shouldShow) {
return null;
}
const variantOptions = buildEffectiveVariantOptions(
currentModel,
currentVariant,
);
const hasModel = Boolean(currentModel);
const firstIsUnavailable =
Boolean(currentVariant) &&
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
@@ -464,8 +283,9 @@ export function OmoFormFields({
onValueChange={(value) =>
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
}
disabled={!hasModel}
>
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
<SelectTrigger className="w-32 h-8 text-xs shrink-0">
<SelectValue
placeholder={t("omo.variantPlaceholder", {
defaultValue: "variant",
@@ -476,16 +296,30 @@ export function OmoFormFields({
<SelectItem value={EMPTY_VARIANT_VALUE}>
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
</SelectItem>
{variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
{!hasModel ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{t("omo.selectModelFirst", {
defaultValue: "Select model first",
})}
</SelectItem>
))}
) : variantOptions.length === 0 ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{t("omo.noVariantsForModel", {
defaultValue: "No variants for model",
})}
</SelectItem>
) : (
variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
@@ -702,7 +536,7 @@ export function OmoFormFields({
toast.warning(
t("omo.noEnabledModelsWarning", {
defaultValue:
"No configured models available. Configure OpenCode models first.",
"No enabled models available. Configure and enable OpenCode models first.",
}),
);
return;
@@ -807,17 +641,9 @@ export function OmoFormFields({
<div key={key} className="border-b border-border/30 last:border-b-0">
<div className="flex items-center gap-2 py-1.5">
<div className="w-32 shrink-0">
<div className="flex items-center gap-1 text-sm font-medium">
{def.display}
<span className="relative inline-flex group/tip">
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground cursor-help shrink-0" />
<span className="invisible opacity-0 group-hover/tip:visible group-hover/tip:opacity-100 transition-opacity duration-150 absolute left-0 top-full mt-1 z-50 w-[260px] rounded-md bg-popover text-popover-foreground border border-border shadow-md px-3 py-2 text-xs leading-relaxed font-normal pointer-events-none">
{t(def.tooltipKey)}
</span>
</span>
</div>
<div className="text-sm font-medium">{def.display}</div>
<div className="text-xs text-muted-foreground truncate">
{t(def.descKey)}
{isZh ? def.descZh : def.descEn}
</div>
</div>
{renderModelSelect(
@@ -901,14 +727,16 @@ export function OmoFormFields({
className="border-b border-border/30 last:border-b-0"
>
<div className="flex items-center gap-2 py-1.5">
<DeferredKeyInput
<Input
value={item.key}
onCommit={(value) => updateCustom({ key: value })}
onChange={(e) => updateCustom({ key: e.target.value })}
placeholder={keyPlaceholder}
className="w-32 shrink-0 h-8 text-sm text-primary"
/>
{renderModelSelect(item.model, (value) =>
updateCustom({ model: value }),
{renderModelSelect(
item.model,
(value) => updateCustom({ model: value }),
t("omo.modelNamePlaceholder", { defaultValue: "model-name" }),
)}
{renderVariantSelect(item.model, currentVariant, (value) => {
if (!item.key) return;
@@ -1049,17 +877,11 @@ export function OmoFormFields({
const addCustomModel = (scope: AdvancedScope) => {
if (scope === "agent") {
setCustomAgents((prev) => [
...prev,
{ key: "", model: "", sourceKey: "" },
]);
setCustomAgents((prev) => [...prev, { key: "", model: "" }]);
setSubAgentsOpen(true);
return;
}
setCustomCategories((prev) => [
...prev,
{ key: "", model: "", sourceKey: "" },
]);
setCustomCategories((prev) => [...prev, { key: "", model: "" }]);
setCategoriesOpen(true);
};
@@ -1109,7 +931,7 @@ export function OmoFormFields({
·{" "}
{t("omo.enabledModelsCount", {
count: modelOptions.length,
defaultValue: "{{count}} configured models available",
defaultValue: "{{count}} enabled models available",
})}
</span>
{localFilePath && (
+127 -524
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,
@@ -32,19 +31,12 @@ import {
} from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
OPENCODE_PRESET_MODEL_VARIANTS,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
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,8 +51,9 @@ 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 { mergeOmoConfigPreview } from "@/types/omo";
import {
ProviderAdvancedConfig,
type PricingModelSourceOption,
@@ -73,49 +66,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(
@@ -152,25 +112,21 @@ const isKnownOpencodeOptionKey = (key: string) =>
function parseOpencodeConfig(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const normalize = (
parsed: Partial<OpenCodeProviderConfig>,
): OpenCodeProviderConfig => ({
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
});
try {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return normalize(parsed);
return {
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
};
} catch {
return {
npm: OPENCODE_DEFAULT_NPM,
@@ -180,25 +136,6 @@ function parseOpencodeConfig(
}
}
function parseOpencodeConfigStrict(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return {
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
};
}
function toOpencodeExtraOptions(
options: OpenCodeProviderConfig["options"],
): Record<string, string> {
@@ -258,10 +195,8 @@ function buildOmoProfilePreview(
}
if (otherFieldsStr.trim()) {
try {
const other = parseOmoOtherFieldsObject(otherFieldsStr);
if (other) {
Object.assign(profileOnly, other);
}
const other = JSON.parse(otherFieldsStr);
Object.assign(profileOnly, other);
} catch {}
}
return profileOnly;
@@ -446,12 +381,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 +458,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 +466,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 +482,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 +509,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 +563,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(() => {
@@ -685,24 +580,19 @@ export function ProviderForm({
);
}, [opencodeProvidersData?.providers, providerId]);
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
string[] | null
>(null);
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
const lastOmoModelSourceWarningRef = useRef<string>("");
string[]
>([]);
useEffect(() => {
let active = true;
if (!isOmoCategory) {
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
setEnabledOpencodeProviderIds([]);
return () => {
active = false;
};
}
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
(async () => {
try {
const ids = await providersApi.getOpenCodeLiveProviderIds();
@@ -710,13 +600,9 @@ export function ProviderForm({
setEnabledOpencodeProviderIds(ids);
}
} catch (error) {
console.warn(
"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
error,
);
console.error("Failed to load OpenCode live provider ids:", error);
if (active) {
setOmoLiveIdsLoadFailed(true);
setEnabledOpencodeProviderIds(null);
setEnabledOpencodeProviderIds([]);
}
}
})();
@@ -726,71 +612,23 @@ export function ProviderForm({
};
}, [isOmoCategory]);
const omoModelBuild = useMemo(() => {
const empty = {
options: [] as Array<{ value: string; label: string }>,
variantsMap: {} as Record<string, string[]>,
presetMetaMap: {} as Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>,
parseFailedProviders: [] as string[],
usedFallbackSource: false,
};
if (!isOmoCategory) {
return empty;
}
const omoModelOptions = useMemo(() => {
if (!isOmoCategory) return [];
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) {
return empty;
}
if (!allProviders) return [];
const shouldFilterByLive = !omoLiveIdsLoadFailed;
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
return empty;
}
const liveSet =
shouldFilterByLive && enabledOpencodeProviderIds
? new Set(enabledOpencodeProviderIds)
: null;
const enabledSet = new Set(enabledOpencodeProviderIds);
if (enabledSet.size === 0) return [];
const dedupedOptions = new Map<string, string>();
const variantsMap: Record<string, string[]> = {};
const presetMetaMap: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
> = {};
const parseFailedProviders: string[] = [];
for (const [providerKey, provider] of Object.entries(allProviders)) {
if (provider.category === "omo") {
continue;
}
if (liveSet && !liveSet.has(providerKey)) {
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
continue;
}
let parsedConfig: OpenCodeProviderConfig;
try {
parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);
} catch (error) {
parseFailedProviders.push(providerKey);
console.warn(
"[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings",
{
providerKey,
error,
},
);
continue;
}
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
@@ -807,107 +645,63 @@ export function ProviderForm({
if (!dedupedOptions.has(value)) {
dedupedOptions.set(value, label);
}
const rawVariants = model.variants;
if (
rawVariants &&
typeof rawVariants === "object" &&
!Array.isArray(rawVariants)
) {
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length > 0) {
variantsMap[value] = variantKeys;
}
}
}
// Preset fallback: for models without config-defined variants,
// check if the npm package has preset variant definitions.
// Also collect preset metadata (options, limit) for enrichment.
const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];
if (presetModels) {
for (const modelId of Object.keys(parsedConfig.models || {})) {
const fullKey = `${providerKey}/${modelId}`;
const preset = presetModels.find((p) => p.id === modelId);
if (!preset) continue;
// Variant fallback
if (!variantsMap[fullKey] && preset.variants) {
const presetKeys = Object.keys(preset.variants).filter(Boolean);
if (presetKeys.length > 0) {
variantsMap[fullKey] = presetKeys;
}
}
// Collect preset metadata for model enrichment
const meta: (typeof presetMetaMap)[string] = {};
if (preset.options) meta.options = preset.options;
if (preset.contextLimit || preset.outputLimit) {
meta.limit = {};
if (preset.contextLimit) meta.limit.context = preset.contextLimit;
if (preset.outputLimit) meta.limit.output = preset.outputLimit;
}
if (Object.keys(meta).length > 0) {
presetMetaMap[fullKey] = meta;
}
}
}
}
return {
options: Array.from(dedupedOptions.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN")),
variantsMap,
presetMetaMap,
parseFailedProviders,
usedFallbackSource: omoLiveIdsLoadFailed,
};
return Array.from(dedupedOptions.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
}, [
isOmoCategory,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
omoLiveIdsLoadFailed,
]);
const omoModelOptions = omoModelBuild.options;
const omoModelVariantsMap = omoModelBuild.variantsMap;
const omoPresetMetaMap = omoModelBuild.presetMetaMap;
useEffect(() => {
if (!isOmoCategory) return;
const failed = omoModelBuild.parseFailedProviders;
const fallback = omoModelBuild.usedFallbackSource;
if (failed.length === 0 && !fallback) return;
const signature = `${fallback ? "fallback:" : ""}${failed
.slice()
.sort()
.join(",")}`;
if (lastOmoModelSourceWarningRef.current === signature) return;
lastOmoModelSourceWarningRef.current = signature;
if (failed.length > 0) {
toast.warning(
t("omo.modelSourcePartialWarning", {
count: failed.length,
defaultValue:
"Some provider model configs are invalid and were skipped.",
}),
);
const omoModelVariantsMap = useMemo(() => {
const variantsMap: Record<string, string[]> = {};
if (!isOmoCategory) {
return variantsMap;
}
if (fallback) {
toast.warning(
t("omo.modelSourceFallbackWarning", {
defaultValue:
"Failed to load live provider state. Falling back to configured providers.",
}),
);
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) {
return variantsMap;
}
const enabledSet = new Set(enabledOpencodeProviderIds);
if (enabledSet.size === 0) {
return variantsMap;
}
for (const [providerKey, provider] of Object.entries(allProviders)) {
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
continue;
}
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
const rawVariants = model.variants;
if (
!rawVariants ||
typeof rawVariants !== "object" ||
Array.isArray(rawVariants)
) {
continue;
}
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length === 0) {
continue;
}
variantsMap[`${providerKey}/${modelId}`] = variantKeys;
}
}
return variantsMap;
}, [
isOmoCategory,
omoModelBuild.parseFailedProviders,
omoModelBuild.usedFallbackSource,
t,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
]);
const initialOmoSettings =
@@ -983,13 +777,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 +860,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 +944,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 +1042,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 +1057,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 +1069,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;
}
@@ -1424,19 +1078,7 @@ export function ProviderForm({
}
if (omoOtherFieldsStr.trim()) {
try {
const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr);
if (!otherFields) {
toast.error(
t("omo.jsonMustBeObject", {
field: t("omo.otherFields", {
defaultValue: "Other Config",
}),
defaultValue: "{{field}} must be a JSON object",
}),
);
return;
}
omoConfig.otherFields = otherFields;
omoConfig.otherFields = JSON.parse(omoOtherFieldsStr);
} catch {
toast.error(
t("omo.invalidJson", {
@@ -1448,28 +1090,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 +1168,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 +1180,6 @@ export function ProviderForm({
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
? pricingConfig.pricingModelSource
: undefined,
...(shouldPersistCommonConfigEnabledByApp
? { commonConfigEnabledByApp }
: {}),
// Claude API 格式(仅非官方 Claude 供应商使用)
apiFormat:
appId === "claude" && category !== "official"
? localApiFormat
@@ -1996,7 +1603,6 @@ export function ProviderForm({
<OmoFormFields
modelOptions={omoModelOptions}
modelVariantsMap={omoModelVariantsMap}
presetMetaMap={omoPresetMetaMap}
agents={omoAgents}
onAgentsChange={setOmoAgents}
categories={omoCategories}
@@ -2022,7 +1628,6 @@ export function ProviderForm({
configError={codexConfigError}
onExtract={handleCodexExtract}
isExtracting={isCodexExtracting}
finalConfig={codexFinalConfig}
/>
{settingsConfigErrorField}
</>
@@ -2044,7 +1649,6 @@ export function ProviderForm({
configError={geminiConfigError}
onExtract={handleGeminiExtract}
isExtracting={isGeminiExtracting}
finalEnv={envObjToString(geminiFinalEnv)}
/>
{settingsConfigErrorField}
</>
@@ -2099,7 +1703,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) => {
-103
View File
@@ -1,103 +0,0 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center border-b px-3 focus-within:outline-none focus-within:ring-0"
cmdk-input-wrapper=""
>
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
};
-28
View File
@@ -1,28 +0,0 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "start", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+1 -382
View File
@@ -24,384 +24,6 @@ export const opencodeNpmPackages = [
{ value: "@ai-sdk/google", label: "Google (Gemini)" },
] as const;
export interface PresetModelVariant {
id: string;
name?: string;
contextLimit?: number;
outputLimit?: number;
modalities?: { input: string[]; output: string[] };
options?: Record<string, unknown>;
variants?: Record<string, Record<string, unknown>>;
}
export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
string,
PresetModelVariant[]
> = {
"@ai-sdk/openai-compatible": [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
contextLimit: 204800,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
},
{
id: "glm-4.7",
name: "GLM 4.7",
contextLimit: 204800,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
},
{
id: "kimi-k2.5",
name: "Kimi K2.5",
contextLimit: 262144,
outputLimit: 262144,
modalities: { input: ["text", "image", "video"], output: ["text"] },
},
],
"@ai-sdk/google": [
{
id: "gemini-2.5-flash-lite",
name: "Gemini 2.5 Flash Lite",
contextLimit: 1048576,
outputLimit: 65536,
modalities: {
input: ["text", "image", "pdf", "video", "audio"],
output: ["text"],
},
variants: {
auto: {
thinkingConfig: { includeThoughts: true, thinkingBudget: -1 },
},
"no-thinking": { thinkingConfig: { thinkingBudget: 0 } },
},
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash Preview",
contextLimit: 1048576,
outputLimit: 65536,
modalities: {
input: ["text", "image", "pdf", "video", "audio"],
output: ["text"],
},
variants: {
minimal: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" },
},
low: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "low" },
},
medium: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" },
},
high: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "high" },
},
},
},
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro Preview",
contextLimit: 1048576,
outputLimit: 65536,
modalities: {
input: ["text", "image", "pdf", "video", "audio"],
output: ["text"],
},
variants: {
low: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "low" },
},
high: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "high" },
},
},
},
],
"@ai-sdk/openai": [
{
id: "gpt-5",
name: "GPT-5",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "low",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "high",
},
},
},
{
id: "gpt-5.1",
name: "GPT-5.1",
contextLimit: 400000,
outputLimit: 272000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "low",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "high",
},
},
},
{
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.1-codex-max",
name: "GPT-5.1 Codex Max",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.2",
name: "GPT-5.2",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
],
"@ai-sdk/anthropic": [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { effort: "low" },
medium: { effort: "medium" },
high: { effort: "high" },
},
},
{
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinking: { budgetTokens: 5000, type: "enabled" } },
medium: { thinking: { budgetTokens: 13000, type: "enabled" } },
high: { thinking: { budgetTokens: 18000, type: "enabled" } },
},
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextLimit: 1000000,
outputLimit: 128000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { effort: "low" },
medium: { effort: "medium" },
high: { effort: "high" },
max: { effort: "max" },
},
},
{
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
{
id: "gemini-claude-opus-4-5-thinking",
name: "Antigravity - Claude Opus 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { effort: "low" },
medium: { effort: "medium" },
high: { effort: "high" },
},
},
{
id: "gemini-claude-sonnet-4-5-thinking",
name: "Antigravity - Claude Sonnet 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinking: { budgetTokens: 5000, type: "enabled" } },
medium: { thinking: { budgetTokens: 13000, type: "enabled" } },
high: { thinking: { budgetTokens: 18000, type: "enabled" } },
},
},
],
};
/**
* Look up preset metadata for a model by npm package and model ID.
* Returns enrichment fields (options, limit, modalities) that can be
* merged into a model definition when the user's config doesn't already
* provide them.
*/
export function getPresetModelDefaults(
npm: string,
modelId: string,
): PresetModelVariant | undefined {
const models = OPENCODE_PRESET_MODEL_VARIANTS[npm];
if (!models) return undefined;
return models.find((m) => m.id === modelId);
}
export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
{
name: "DeepSeek",
@@ -1042,10 +664,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"gpt-5.2": { name: "GPT-5.2" },
"gpt-5.2-codex": {
name: "GPT-5.2 Codex",
options: { include: ["reasoning.encrypted_content"], store: false },
},
"gpt-5.2-codex": { name: "GPT-5.2 Codex" },
},
},
category: "third_party",
-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;
}
+10 -77
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",
@@ -546,10 +538,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 +603,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 +621,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",
@@ -1590,21 +1574,16 @@
"clearWrapped": "(Clear)",
"defaultWrapped": "(Default)",
"variantPlaceholder": "variant",
"selectEnabledModel": "Select configured model",
"selectModel": "Select configured model",
"recommendedHint": "Recommended: {{model}}",
"searchModel": "Search model...",
"selectEnabledModel": "Select enabled model",
"selectModelFirst": "Select model first",
"noEnabledModels": "No configured models",
"noEnabledModels": "No enabled models",
"noVariantsForModel": "No variants for model",
"currentValueNotEnabled": "{{value}} (current value, not configured)",
"currentValueNotEnabled": "{{value}} (current value, not enabled)",
"currentValueUnavailable": "{{value}} (current value, unavailable)",
"advancedLabel": "Advanced",
"advancedJsonInvalid": "Advanced JSON is invalid",
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults",
"noEnabledModelsWarning": "No configured models available. Configure OpenCode models first.",
"modelSourcePartialWarning": "Some provider model configs are invalid and were skipped.",
"modelSourceFallbackWarning": "Failed to load live provider state. Falling back to configured providers.",
"noEnabledModelsWarning": "No enabled models available. Configure and enable OpenCode models first.",
"importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields",
"importLocalFailed": "Failed to read local file: {{error}}",
"agentKeyPlaceholder": "agent key",
@@ -1615,7 +1594,7 @@
"modelConfiguration": "Model Configuration",
"fillRecommended": "Fill Recommended",
"configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params",
"enabledModelsCount": "{{count}} configured models available",
"enabledModelsCount": "{{count}} enabled models available",
"source": "from:",
"otherFieldsJson": "Other Fields (JSON)",
"searchOrType": "Search or type custom value...",
@@ -1641,52 +1620,6 @@
"advancedExperimental": "Experimental Features",
"advancedBackgroundTask": "Background Tasks",
"advancedBrowserAutomation": "Browser Automation",
"advancedClaudeCode": "Claude Code",
"agentDesc": {
"sisyphus": "Main orchestrator",
"hephaestus": "Autonomous deep worker",
"prometheus": "Strategic planner",
"atlas": "Task manager",
"oracle": "Strategic advisor",
"librarian": "Multi-repo researcher",
"explore": "Fast code search",
"multimodalLooker": "Media analyzer",
"metis": "Pre-plan analysis advisor",
"momus": "Plan reviewer",
"sisyphusJunior": "Delegated task executor"
},
"agentTooltip": {
"sisyphus": "Main orchestrator responsible for task planning, delegation and parallel execution. Uses extended thinking (32k budget) and drives workflows through TODO lists to ensure task completion.",
"atlas": "Main orchestrator (holds TODO list) responsible for task distribution and coordination during execution phase. Delegates to specialized agents rather than completing all work directly.",
"prometheus": "Strategic planner that collects requirements through interview mode and creates detailed work plans. Can only read/write Markdown files in .sisyphus/ directory, never writes code directly.",
"hephaestus": "Autonomous deep worker (\"legitimate craftsman\") inspired by AmpCode deep mode. Goal-oriented execution that launches 2-5 explore/librarian agents in parallel for research before taking action.",
"oracle": "Architecture decision and debugging advisor. Read-only consulting agent providing excellent logical reasoning and deep analysis. Cannot write files or delegate tasks.",
"librarian": "Multi-repository analysis and documentation retrieval expert. Deeply understands codebases and provides evidence-based answers. Excels at finding official documentation and open-source implementation examples.",
"explore": "Fast codebase exploration and context grep expert. Uses lightweight models for high-speed search. The scout for understanding code structure.",
"multimodalLooker": "Visual content expert that analyzes PDFs, images, charts and other non-text media, extracting information and insights from them.",
"metis": "Plan consultant that performs pre-analysis before planning. Identifies hidden intents, ambiguities and AI failure points to prevent over-engineering.",
"momus": "Plan reviewer that validates plan clarity, verifiability and completeness with high precision. Rejects and requests revisions until plans are perfect.",
"sisyphusJunior": "Category-generated executor, automatically created via category parameters. Focuses on executing assigned tasks and cannot re-delegate, preventing infinite delegation loops."
},
"categoryDesc": {
"visualEngineering": "Visual/frontend engineering",
"ultrabrain": "Ultra thinking",
"deep": "Deep work",
"artistry": "Creative/artistic",
"quick": "Quick response",
"unspecifiedLow": "General low tier",
"unspecifiedHigh": "General high tier",
"writing": "Writing"
},
"categoryTooltip": {
"visualEngineering": "Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3 Pro model.",
"ultrabrain": "Deep logical reasoning category for complex architectural decisions requiring extensive analysis. Defaults to GPT-5.3 Codex ultra-high reasoning variant.",
"deep": "Deep autonomous problem-solving category with goal-oriented execution. Conducts thorough research before action, suitable for difficult problems requiring deep understanding.",
"artistry": "Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3 Pro max variant.",
"quick": "Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.",
"unspecifiedLow": "Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.",
"unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.6 max variant.",
"writing": "Writing category for documentation, prose and technical writing. Defaults to Gemini 3 Flash fast generation model."
}
"advancedClaudeCode": "Claude Code"
}
}
+10 -77
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 で見る",
@@ -546,10 +538,7 @@
"categoryOfficial": "公式",
"categoryCnOfficial": "オープンソース公式",
"categoryAggregation": "アグリゲーター",
"categoryThirdParty": "サードパーティ",
"commonConfigLoading": "共通設定を読み込み中です。保存する前にお待ちください",
"commonConfigSyncFailed": "共通設定の同期に失敗しました。再試行してください",
"saveCommonConfigFailed": "共通設定の保存に失敗しました"
"categoryThirdParty": "サードパーティ"
},
"endpointTest": {
"title": "API エンドポイント管理",
@@ -614,12 +603,10 @@
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
"apiUrlLabel": "API リクエスト URL",
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"extractSuccessNeedSave": "共通設定を抽出しました。保存ボタンをクリックして完了してください",
"saveFailed": "保存に失敗しました: {{error}}"
},
"geminiConfig": {
@@ -634,17 +621,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 フォーマット",
@@ -1571,21 +1555,16 @@
"clearWrapped": "(クリア)",
"defaultWrapped": "(デフォルト)",
"variantPlaceholder": "variant",
"selectEnabledModel": "設定済みモデルを選択",
"selectModel": "設定済みモデルを選択",
"recommendedHint": "推奨: {{model}}",
"searchModel": "モデルを検索...",
"selectEnabledModel": "有効なモデルを選択",
"selectModelFirst": "先にモデルを選択",
"noEnabledModels": "設定済みモデルがありません",
"noEnabledModels": "有効なモデルがありません",
"noVariantsForModel": "このモデルには思考レベルがありません",
"currentValueNotEnabled": "{{value}} (現在値・未設定)",
"currentValueNotEnabled": "{{value}} (現在値・未有効)",
"currentValueUnavailable": "{{value}} (現在値・利用不可)",
"advancedLabel": "詳細",
"advancedJsonInvalid": "詳細 JSON が不正です",
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission など。空欄でデフォルトを使用します",
"noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。",
"modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。",
"modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。",
"noEnabledModelsWarning": "利用可能な有効モデルがありません。先に OpenCode モデルを有効化してください。",
"importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました",
"importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}",
"agentKeyPlaceholder": "agent キー",
@@ -1596,7 +1575,7 @@
"modelConfiguration": "モデル設定",
"fillRecommended": "推奨を入力",
"configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開",
"enabledModelsCount": "設定済みモデル {{count}} 件",
"enabledModelsCount": "有効モデル {{count}} 件",
"source": "出典:",
"otherFieldsJson": "その他のフィールド (JSON)",
"searchOrType": "検索またはカスタム値を入力...",
@@ -1622,52 +1601,6 @@
"advancedExperimental": "実験的機能",
"advancedBackgroundTask": "バックグラウンドタスク",
"advancedBrowserAutomation": "ブラウザ自動化",
"advancedClaudeCode": "Claude Code",
"agentDesc": {
"sisyphus": "メインオーケストレーター",
"hephaestus": "自律型ディープワーカー",
"prometheus": "戦略プランナー",
"atlas": "タスクマネージャー",
"oracle": "戦略アドバイザー",
"librarian": "マルチリポジトリ研究員",
"explore": "高速コード検索",
"multimodalLooker": "メディアアナライザー",
"metis": "計画前分析アドバイザー",
"momus": "プランレビュアー",
"sisyphusJunior": "委任タスクエグゼキューター"
},
"agentTooltip": {
"sisyphus": "メインオーケストレーター。タスクの計画、委任、並列実行を担当。拡張思考(32k バジェット)を使用し、TODO リストでワークフローを駆動してタスク完了を確保します。",
"atlas": "メインオーケストレーター(TODO リスト保持)。実行フェーズでのタスク配分と調整を担当。すべての作業を直接行うのではなく、専門エージェントに委任します。",
"prometheus": "戦略プランナー。インタビューモードで要件を収集し、詳細な作業計画を策定。.sisyphus/ ディレクトリ内の Markdown ファイルの読み書きのみ可能で、直接コードを書くことはありません。",
"hephaestus": "自律型ディープワーカー(「正当な職人」)。AmpCode ディープモードに着想を得た目標指向の実行を行い、行動前に 2-5 個の探索/ライブラリアンエージェントを並列起動して調査します。",
"oracle": "アーキテクチャ決定とデバッグのアドバイザー。読み取り専用のコンサルティングエージェントで、優れた論理的推論と深い分析を提供。ファイルの書き込みやタスクの委任はできません。",
"librarian": "マルチリポジトリ分析とドキュメント検索の専門家。コードベースを深く理解し、エビデンスに基づく回答を提供。公式ドキュメントやオープンソース実装例の検索に長けています。",
"explore": "高速コードベース探索とコンテキスト grep の専門家。軽量モデルを使用した高速検索で、コード構造を理解するための先鋒です。",
"multimodalLooker": "ビジュアルコンテンツの専門家。PDF、画像、グラフなどの非テキストメディアを分析し、情報とインサイトを抽出します。",
"metis": "プランコンサルタント。計画前に事前分析を行い、隠れた意図、曖昧な点、AI の失敗ポイントを特定して、過剰エンジニアリングを防止します。",
"momus": "プランレビュアー。計画の明確さ、検証可能性、完全性を高精度で検証。計画が完璧になるまで却下と修正を要求します。",
"sisyphusJunior": "カテゴリ生成のエグゼキューター。category パラメータにより自動生成され、割り当てられたタスクの実行に専念し、再委任はできません。無限委任ループを防止します。"
},
"categoryDesc": {
"visualEngineering": "ビジュアル/フロントエンド工学",
"ultrabrain": "超深度思考",
"deep": "ディープワーク",
"artistry": "クリエイティブ/芸術",
"quick": "クイックレスポンス",
"unspecifiedLow": "汎用ロースペック",
"unspecifiedHigh": "汎用ハイスペック",
"writing": "ライティング"
},
"categoryTooltip": {
"visualEngineering": "フロントエンドとビジュアルエンジニアリングカテゴリ。UI/UX デザイン、スタイリング、アニメーション、インターフェース実装に特化。デフォルトで Gemini 3 Pro モデルを使用。",
"ultrabrain": "ディープロジック推論カテゴリ。広範な分析が必要な複雑なアーキテクチャ決定に使用。デフォルトで GPT-5.3 Codex の超高推論バリアントを使用。",
"deep": "ディープ自律問題解決カテゴリ。目標指向の実行で、行動前に徹底的な調査を実施。深い理解が必要な難問に適しています。",
"artistry": "高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3 Pro の max バリアントを使用。",
"quick": "軽量タスクカテゴリ。単一ファイルの修正、タイポ修正、簡単な調整などの些細な作業に使用。デフォルトで Claude Haiku 4.5 高速モデルを使用。",
"unspecifiedLow": "未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。",
"unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.6 の max バリアントを使用。",
"writing": "ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Gemini 3 Flash 高速生成モデルを使用。"
}
"advancedClaudeCode": "Claude Code"
}
}
+11 -78
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 上查看",
@@ -546,10 +538,7 @@
"categoryOfficial": "官方",
"categoryCnOfficial": "开源官方",
"categoryAggregation": "聚合服务",
"categoryThirdParty": "第三方",
"commonConfigLoading": "通用配置正在加载中,请稍候再保存",
"commonConfigSyncFailed": "通用配置同步部分失败,请重试",
"saveCommonConfigFailed": "保存通用配置失败"
"categoryThirdParty": "第三方"
},
"endpointTest": {
"title": "请求地址管理",
@@ -614,12 +603,10 @@
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
"apiUrlLabel": "API 请求地址",
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"extractSuccessNeedSave": "已提取通用配置,点击保存按钮完成保存",
"saveFailed": "保存失败: {{error}}"
},
"geminiConfig": {
@@ -634,17 +621,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": "接口格式",
@@ -1590,21 +1574,16 @@
"clearWrapped": "(清空)",
"defaultWrapped": "(默认)",
"variantPlaceholder": "思考等级",
"selectEnabledModel": "选择已配置模型",
"selectModel": "选择已配置模型",
"recommendedHint": "推荐: {{model}}",
"searchModel": "搜索模型...",
"selectEnabledModel": "选择已启用模型",
"selectModelFirst": "先选择模型",
"noEnabledModels": "暂无已配置模型",
"noEnabledModels": "暂无已启用模型",
"noVariantsForModel": "该模型无思考等级",
"currentValueNotEnabled": "{{value}}(当前值,未配置",
"currentValueUnavailable": "{{value}}(当前值,不可用)",
"currentValueNotEnabled": "{{value}}(当前值,未启用",
"currentValueUnavailable": "{{value}}(当前值,未启用)",
"advancedLabel": "高级参数",
"advancedJsonInvalid": "高级参数 JSON 无效",
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission 等,留空使用默认值",
"noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型",
"modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。",
"modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。",
"noEnabledModelsWarning": "当前没有可用的已启用模型,请先启用并配置 OpenCode 模型",
"importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields",
"importLocalFailed": "读取本地文件失败: {{error}}",
"agentKeyPlaceholder": "agent 键名",
@@ -1615,7 +1594,7 @@
"modelConfiguration": "模型配置",
"fillRecommended": "填充推荐",
"configSummary": "已配置 {{agents}} 个 Agent{{categories}} 个 Category · 点击 ⚙ 展开高级参数",
"enabledModelsCount": "可选已配置模型 {{count}} 个",
"enabledModelsCount": "可选已启用模型 {{count}} 个",
"source": "来源:",
"otherFieldsJson": "其他字段 (JSON)",
"searchOrType": "搜索或输入自定义值...",
@@ -1641,52 +1620,6 @@
"advancedExperimental": "实验性功能",
"advancedBackgroundTask": "后台任务",
"advancedBrowserAutomation": "浏览器自动化",
"advancedClaudeCode": "Claude Code",
"agentDesc": {
"sisyphus": "主编排者",
"hephaestus": "自主深度工作者",
"prometheus": "战略规划者",
"atlas": "任务管理者",
"oracle": "战略顾问",
"librarian": "多仓库研究员",
"explore": "快速代码搜索",
"multimodalLooker": "媒体分析器",
"metis": "规划前分析顾问",
"momus": "计划审查者",
"sisyphusJunior": "委托任务执行器"
},
"agentTooltip": {
"sisyphus": "主编排器,负责任务规划、委派与并行执行,使用扩展思考(32k 预算),通过 TODO 驱动工作流确保任务完成。",
"atlas": "主编排器(持有 TODO 列表),负责执行阶段的任务分发与协调,不直接完成所有工作而是委派给专业代理。",
"prometheus": "战略规划师,通过访谈模式收集需求并制定详细工作计划,仅能在 .sisyphus/ 目录读写 Markdown 文件,从不直接写代码。",
"hephaestus": "自主深度工作者(「合法工匠」),受 AmpCode 深度模式启发,目标导向执行,行动前会并行启动 2-5 个探索/图书管理员代理进行研究。",
"oracle": "架构决策与调试顾问,只读咨询代理,提供出色的逻辑推理和深度分析,不能写文件或委派任务。",
"librarian": "多仓库分析与文档检索专家,深度理解代码库并提供基于证据的答案,擅长查找官方文档和开源实现示例。",
"explore": "快速代码库探索与上下文 grep 专家,使用轻量级模型进行高速搜索,是理解代码结构的先锋。",
"multimodalLooker": "视觉内容专家,分析 PDF、图片、图表等非文本媒体,提取其中的信息与洞察。",
"metis": "计划顾问,在规划前进行预分析,识别隐藏意图、模糊点和 AI 失败点,防止过度工程化。",
"momus": "计划评审员,高精度验证计划的清晰度、可验证性和完整性,拒绝并要求修订直到计划完美。",
"sisyphusJunior": "类别生成的执行器,由 category 参数自动生成,专注于执行分配的任务且不能再委派,防止无限委派循环。"
},
"categoryDesc": {
"visualEngineering": "视觉/前端工程",
"ultrabrain": "超级思考",
"deep": "深度工作",
"artistry": "创意/文艺",
"quick": "快速响应",
"unspecifiedLow": "通用低配",
"unspecifiedHigh": "通用高配",
"writing": "写作"
},
"categoryTooltip": {
"visualEngineering": "前端与视觉工程类别,专注于 UI/UX 设计、样式、动画和界面实现,默认使用 Gemini 3 Pro 模型。",
"ultrabrain": "深度逻辑推理类别,用于需要广泛分析的复杂架构决策,默认使用 GPT-5.3 Codex 的超高推理变体。",
"deep": "深度自主问题解决类别,目标导向执行,行动前进行彻底研究,适用于需要深度理解的棘手问题。",
"artistry": "高度创意与艺术性任务类别,激发新颖想法和创造性解决方案,默认使用 Gemini 3 Pro 的最大变体。",
"quick": "轻量任务类别,用于单文件修改、错别字修复、简单调整等琐碎工作,默认使用 Claude Haiku 4.5 快速模型。",
"unspecifiedLow": "未归类低工作量任务类别,适用于不适合其他类别且工作量较小的任务,默认使用 Claude Sonnet 4.5。",
"unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.6 的最大变体。",
"writing": "写作类别,专注于文档、散文和技术写作,默认使用 Gemini 3 Flash 快速生成模型。"
}
"advancedClaudeCode": "Claude Code"
}
}
+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 };
}
}
-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 格式,需要格式转换
+50 -62
View File
@@ -27,8 +27,8 @@ export interface OmoLocalFileData {
export interface OmoAgentDef {
key: string;
display: string;
descKey: string;
tooltipKey: string;
descZh: string;
descEn: string;
recommended?: string;
group: "main" | "sub";
}
@@ -36,97 +36,97 @@ export interface OmoAgentDef {
export interface OmoCategoryDef {
key: string;
display: string;
descKey: string;
tooltipKey: string;
descZh: string;
descEn: string;
recommended?: string;
}
export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
{
key: "sisyphus",
key: "Sisyphus",
display: "Sisyphus",
descKey: "omo.agentDesc.sisyphus",
tooltipKey: "omo.agentTooltip.sisyphus",
descZh: "主编排者",
descEn: "Main orchestrator",
recommended: "claude-opus-4-6",
group: "main",
},
{
key: "hephaestus",
key: "Hephaestus",
display: "Hephaestus",
descKey: "omo.agentDesc.hephaestus",
tooltipKey: "omo.agentTooltip.hephaestus",
descZh: "自主深度工作者",
descEn: "Autonomous deep worker",
recommended: "gpt-5.3-codex",
group: "main",
},
{
key: "prometheus",
key: "Prometheus",
display: "Prometheus",
descKey: "omo.agentDesc.prometheus",
tooltipKey: "omo.agentTooltip.prometheus",
descZh: "战略规划者",
descEn: "Strategic planner",
recommended: "claude-opus-4-6",
group: "main",
},
{
key: "atlas",
key: "Atlas",
display: "Atlas",
descKey: "omo.agentDesc.atlas",
tooltipKey: "omo.agentTooltip.atlas",
descZh: "任务管理者",
descEn: "Task manager",
recommended: "kimi-k2.5",
group: "main",
},
{
key: "oracle",
display: "Oracle",
descKey: "omo.agentDesc.oracle",
tooltipKey: "omo.agentTooltip.oracle",
descZh: "战略顾问",
descEn: "Strategic advisor",
recommended: "gpt-5.3",
group: "sub",
},
{
key: "librarian",
display: "Librarian",
descKey: "omo.agentDesc.librarian",
tooltipKey: "omo.agentTooltip.librarian",
descZh: "多仓库研究员",
descEn: "Multi-repo researcher",
recommended: "glm-4.7",
group: "sub",
},
{
key: "explore",
display: "Explore",
descKey: "omo.agentDesc.explore",
tooltipKey: "omo.agentTooltip.explore",
descZh: "快速代码搜索",
descEn: "Fast code search",
recommended: "grok-code-fast-1",
group: "sub",
},
{
key: "multimodal-looker",
display: "Multimodal-Looker",
descKey: "omo.agentDesc.multimodalLooker",
tooltipKey: "omo.agentTooltip.multimodalLooker",
descZh: "媒体分析器",
descEn: "Media analyzer",
recommended: "gemini-3-flash",
group: "sub",
},
{
key: "metis",
key: "Metis",
display: "Metis",
descKey: "omo.agentDesc.metis",
tooltipKey: "omo.agentTooltip.metis",
descZh: "规划前分析顾问",
descEn: "Pre-plan analysis advisor",
recommended: "claude-opus-4-6",
group: "sub",
},
{
key: "momus",
key: "Momus",
display: "Momus",
descKey: "omo.agentDesc.momus",
tooltipKey: "omo.agentTooltip.momus",
descZh: "计划审查者",
descEn: "Plan reviewer",
recommended: "gpt-5.3",
group: "sub",
},
{
key: "sisyphus-junior",
key: "Sisyphus-Junior",
display: "Sisyphus-Junior",
descKey: "omo.agentDesc.sisyphusJunior",
tooltipKey: "omo.agentTooltip.sisyphusJunior",
descZh: "委托任务执行器",
descEn: "Delegated task executor",
group: "sub",
},
];
@@ -135,57 +135,57 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [
{
key: "visual-engineering",
display: "Visual Engineering",
descKey: "omo.categoryDesc.visualEngineering",
tooltipKey: "omo.categoryTooltip.visualEngineering",
descZh: "视觉/前端工程",
descEn: "Visual/frontend engineering",
recommended: "gemini-3-pro",
},
{
key: "ultrabrain",
display: "Ultrabrain",
descKey: "omo.categoryDesc.ultrabrain",
tooltipKey: "omo.categoryTooltip.ultrabrain",
descZh: "超级思考",
descEn: "Ultra thinking",
recommended: "claude-opus-4-6",
},
{
key: "deep",
display: "Deep",
descKey: "omo.categoryDesc.deep",
tooltipKey: "omo.categoryTooltip.deep",
descZh: "深度工作",
descEn: "Deep work",
recommended: "gpt-5.3-codex",
},
{
key: "artistry",
display: "Artistry",
descKey: "omo.categoryDesc.artistry",
tooltipKey: "omo.categoryTooltip.artistry",
descZh: "创意/文艺",
descEn: "Creative/artistic",
recommended: "claude-opus-4-6",
},
{
key: "quick",
display: "Quick",
descKey: "omo.categoryDesc.quick",
tooltipKey: "omo.categoryTooltip.quick",
descZh: "快速响应",
descEn: "Quick response",
recommended: "gemini-3-flash",
},
{
key: "unspecified-low",
display: "Unspecified Low",
descKey: "omo.categoryDesc.unspecifiedLow",
tooltipKey: "omo.categoryTooltip.unspecifiedLow",
descZh: "通用低配",
descEn: "General low tier",
recommended: "gemini-3-flash",
},
{
key: "unspecified-high",
display: "Unspecified High",
descKey: "omo.categoryDesc.unspecifiedHigh",
tooltipKey: "omo.categoryTooltip.unspecifiedHigh",
descZh: "通用高配",
descEn: "General high tier",
recommended: "gpt-5.3-codex",
},
{
key: "writing",
display: "Writing",
descKey: "omo.categoryDesc.writing",
tooltipKey: "omo.categoryTooltip.writing",
descZh: "写作",
descEn: "Writing",
recommended: "claude-opus-4-6",
},
];
@@ -316,17 +316,6 @@ export const OMO_CLAUDE_CODE_PLACEHOLDER = `{
"plugins": true
}`;
export function parseOmoOtherFieldsObject(
raw: string,
): Record<string, unknown> | undefined {
if (!raw.trim()) return undefined;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return undefined;
}
return parsed as Record<string, unknown>;
}
export function mergeOmoConfigPreview(
global: OmoGlobalConfig,
agents: Record<string, Record<string, unknown>>,
@@ -362,8 +351,7 @@ export function mergeOmoConfigPreview(
if (Object.keys(agents).length > 0) result["agents"] = agents;
if (Object.keys(categories).length > 0) result["categories"] = categories;
try {
const other = parseOmoOtherFieldsObject(otherFieldsStr);
if (!other) return result;
const other = JSON.parse(otherFieldsStr || "{}");
for (const [k, v] of Object.entries(other)) {
result[k] = v;
}
-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!);
};
@@ -1,111 +0,0 @@
import { describe, expect, it } from "vitest";
import {
mergeCustomModelsIntoStore,
type CustomModelItem,
} from "@/components/providers/forms/OmoFormFields";
describe("mergeCustomModelsIntoStore", () => {
it("保留自定义项高级字段,并在模型变更时仅按需清理非法 variant", () => {
const store = {
sisyphus: { model: "builtin-model" },
"custom-agent": {
model: "model-a",
variant: "fast",
temperature: 0.2,
permission: { edit: "allow" },
},
};
const customs: CustomModelItem[] = [
{ key: "custom-agent", model: "model-b", sourceKey: "custom-agent" },
];
const merged = mergeCustomModelsIntoStore(
store,
new Set(["sisyphus"]),
customs,
{ "model-b": ["precise"] },
);
expect(merged.sisyphus).toEqual({ model: "builtin-model" });
expect(merged["custom-agent"]).toEqual({
model: "model-b",
temperature: 0.2,
permission: { edit: "allow" },
});
});
it("重命名自定义 key 时迁移原有 variant 和高级字段", () => {
const store = {
sisyphus: { model: "builtin-model" },
"custom-agent-old": {
model: "model-a",
variant: "fast",
maxTokens: 8192,
},
};
const customs: CustomModelItem[] = [
{
key: "custom-agent-new",
sourceKey: "custom-agent-old",
model: "model-a",
},
];
const merged = mergeCustomModelsIntoStore(
store,
new Set(["sisyphus"]),
customs,
{ "model-a": ["fast", "balanced"] },
);
expect(merged["custom-agent-old"]).toBeUndefined();
expect(merged["custom-agent-new"]).toEqual({
model: "model-a",
variant: "fast",
maxTokens: 8192,
});
});
it("custom 列表为空时移除旧自定义项但保留内置项", () => {
const store = {
sisyphus: { model: "builtin-model" },
hephaestus: { model: "builtin-model-2" },
"custom-agent": { model: "model-a", temperature: 0.3 },
};
const merged = mergeCustomModelsIntoStore(
store,
new Set(["sisyphus", "hephaestus"]),
[],
{},
);
expect(merged).toEqual({
sisyphus: { model: "builtin-model" },
hephaestus: { model: "builtin-model-2" },
});
});
it("清空 model 时保留高级字段并移除 model/variant", () => {
const store = {
sisyphus: { model: "builtin-model" },
"custom-agent": {
model: "model-a",
variant: "fast",
temperature: 0.7,
},
};
const customs: CustomModelItem[] = [
{ key: "custom-agent", model: "", sourceKey: "custom-agent" },
];
const merged = mergeCustomModelsIntoStore(
store,
new Set(["sisyphus"]),
customs,
{ "model-a": ["fast"] },
);
expect(merged["custom-agent"]).toEqual({ temperature: 0.7 });
});
});
+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);
});
-50
View File
@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import {
mergeOmoConfigPreview,
parseOmoOtherFieldsObject,
type OmoGlobalConfig,
} from "@/types/omo";
const EMPTY_GLOBAL: OmoGlobalConfig = {
id: "global",
disabledAgents: [],
disabledMcps: [],
disabledHooks: [],
disabledSkills: [],
updatedAt: "2026-01-01T00:00:00.000Z",
};
describe("parseOmoOtherFieldsObject", () => {
it("解析对象 JSON", () => {
expect(parseOmoOtherFieldsObject('{ "foo": 1 }')).toEqual({ foo: 1 });
});
it("数组/字符串返回 undefined", () => {
expect(parseOmoOtherFieldsObject('["a"]')).toBeUndefined();
expect(parseOmoOtherFieldsObject('"hello"')).toBeUndefined();
});
it("非法 JSON 抛出异常", () => {
expect(() => parseOmoOtherFieldsObject("{")).toThrow();
});
});
describe("mergeOmoConfigPreview", () => {
it("只合并 otherFields 的对象值,忽略数组", () => {
const mergedFromArray = mergeOmoConfigPreview(
EMPTY_GLOBAL,
{},
{},
'["a", "b"]',
);
expect(mergedFromArray).toEqual({});
const mergedFromObject = mergeOmoConfigPreview(
EMPTY_GLOBAL,
{},
{},
'{ "foo": "bar" }',
);
expect(mergedFromObject).toEqual({ foo: "bar" });
});
});