mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 05:38:38 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07d00e726e | |||
| 579df5ad99 |
@@ -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",
|
||||
|
||||
Generated
-39
@@ -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)
|
||||
|
||||
Generated
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t_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
|
||||
|
||||
@@ -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(¤t_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(), ¤t_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(¤t_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(), ¤t_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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
// 尝试解析为 JSON(wrapper 格式)
|
||||
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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 验证 JSON,Codex 暂不验证)
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: "" };
|
||||
}
|
||||
|
||||
// 尝试解析为 JSON(wrapper 格式)
|
||||
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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user