mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ab9cd1371 | |||
| 0c6d429fdb | |||
| b9afcbf2b0 | |||
| bf0be82112 | |||
| c7b8e6debc | |||
| 6634a95c38 | |||
| 1580e87303 | |||
| ea29bc20a0 | |||
| 0ef8c127c5 | |||
| d66f196378 | |||
| 580b32dd67 | |||
| 51bf29582d | |||
| aa1231903f | |||
| b8a53f9e36 | |||
| 06cde78945 | |||
| 0d67fba524 | |||
| 3b61fab4b5 | |||
| 982f78af0f | |||
| 9776210da8 | |||
| 188c7af2e4 | |||
| de59facad6 | |||
| 7974650ad8 | |||
| 3f68f78331 | |||
| 40373538d3 | |||
| 11a9bb0c14 | |||
| 9e25ecf475 | |||
| 733605ae5c | |||
| 542d4635c4 | |||
| 4e4a445922 | |||
| d5d7c87fd6 | |||
| 1e1080c813 | |||
| e393dda68e | |||
| 0ecf6891c0 | |||
| cfab768f95 | |||
| 73fea48049 | |||
| e54e4d47ae | |||
| 403c3f0690 | |||
| b3b3c0732a | |||
| 2ff329f6c0 | |||
| 5817c9aa77 | |||
| 6425826e66 | |||
| 2f18764490 | |||
| 7ad5a76c7a | |||
| b1446d0227 | |||
| 3da25e59cd | |||
| 0938bd5e41 | |||
| 62523ee17e | |||
| 0e75193e84 | |||
| 078a3a6a53 | |||
| d52f8855ea | |||
| 628a659c0e | |||
| 86cfa452e9 | |||
| 06d69caf79 | |||
| 58f7be1517 | |||
| 5d48dd7908 | |||
| 0e7e04581d | |||
| 788e138ece | |||
| b692bb4053 |
Generated
+1
@@ -5634,6 +5634,7 @@ 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 = "0.8"
|
||||
toml = { version = "0.8", features = ["preserve_order"] }
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
|
||||
@@ -339,6 +339,20 @@ 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 {
|
||||
@@ -378,7 +392,9 @@ pub struct MultiAppConfig {
|
||||
#[serde(default)]
|
||||
pub skills: SkillStore,
|
||||
/// 通用配置片段(按应用分治)
|
||||
#[serde(default)]
|
||||
/// 注意:此字段主要用于从旧版 config.json 迁移数据到数据库
|
||||
/// 迁移成功后会被清空,空时不写入 config.json
|
||||
#[serde(default, skip_serializing_if = "CommonConfigSnippets::is_empty")]
|
||||
pub common_config_snippets: CommonConfigSnippets,
|
||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -204,11 +204,36 @@ pub async fn set_common_config_snippet(
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
match app_type.as_str() {
|
||||
"claude" | "gemini" | "omo" => {
|
||||
"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 格式
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
//! 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,58 +180,68 @@ 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
|
||||
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);
|
||||
}
|
||||
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();
|
||||
|
||||
// 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}")))?
|
||||
// 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,
|
||||
})
|
||||
} 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)
|
||||
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,
|
||||
None
|
||||
};
|
||||
|
||||
// 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: Some(usage_script),
|
||||
usage_script,
|
||||
common_config_enabled: Some(false),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ mod claude_plugin;
|
||||
mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod config_merge;
|
||||
mod database;
|
||||
mod deeplink;
|
||||
mod error;
|
||||
|
||||
@@ -191,6 +191,19 @@ 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 {
|
||||
@@ -230,6 +243,18 @@ 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 格式,需要转换
|
||||
@@ -631,7 +656,7 @@ pub struct OpenCodeModelLimit {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod provider_tests {
|
||||
use super::{
|
||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
||||
ProviderManager, ProviderMeta, UniversalProvider,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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};
|
||||
@@ -142,6 +143,29 @@ 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() {
|
||||
@@ -160,11 +184,7 @@ 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 = p
|
||||
.settings_config
|
||||
.get("useCommonConfig")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let use_common_config = Self::resolve_common_config_enabled(p);
|
||||
(agents, categories, other_fields, use_common_config)
|
||||
});
|
||||
|
||||
@@ -237,7 +257,6 @@ 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() {
|
||||
@@ -263,7 +282,13 @@ impl OmoService {
|
||||
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
meta: Some(ProviderMeta {
|
||||
common_config_enabled_by_app: Some(CommonConfigEnabledByApp {
|
||||
opencode: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
//! 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;
|
||||
|
||||
@@ -9,6 +17,7 @@ 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;
|
||||
@@ -104,24 +113,86 @@ impl LiveSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write live configuration snapshot for a provider
|
||||
/// 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.
|
||||
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(&provider.settings_config);
|
||||
let settings = sanitize_claude_settings_for_live(config_to_write);
|
||||
write_json_file(&path, &settings)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
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 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 config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
|
||||
AppError::Config(
|
||||
"CODEX_CONFIG_MISSING_CONFIG: settings_config missing 'config' field or not a string".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let auth_path = get_codex_auth_path();
|
||||
@@ -131,7 +202,12 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
}
|
||||
AppType::Gemini => {
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
write_gemini_live(provider)?;
|
||||
// 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)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - write provider to config
|
||||
@@ -139,7 +215,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
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) = provider.settings_config.as_object() {
|
||||
let config_to_write = if let Some(obj) = config_to_write.as_object() {
|
||||
// Detect full config structure (has $schema or top-level provider field)
|
||||
if obj.contains_key("$schema") || obj.contains_key("provider") {
|
||||
log::warn!(
|
||||
@@ -227,6 +303,8 @@ 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
|
||||
@@ -244,7 +322,8 @@ 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) {
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
// Use write_live_snapshot_with_merge to support common config runtime merge
|
||||
write_live_snapshot_with_merge(state, &app_type, provider)?;
|
||||
}
|
||||
// Note: get_effective_current_provider already validates existence,
|
||||
// so providers.get() should always succeed here
|
||||
|
||||
@@ -13,6 +13,9 @@ 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;
|
||||
@@ -27,7 +30,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;
|
||||
pub(crate) use live::{write_live_snapshot, write_live_snapshot_with_merge};
|
||||
|
||||
// Internal re-exports
|
||||
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||
@@ -181,7 +184,8 @@ impl ProviderService {
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
// Use write_live_snapshot_with_merge to support common config runtime merge
|
||||
write_live_snapshot_with_merge(state, &app_type, &provider)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
@@ -241,7 +245,8 @@ impl ProviderService {
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
} else {
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
// Use write_live_snapshot_with_merge to support common config runtime merge
|
||||
write_live_snapshot_with_merge(state, &app_type, &provider)?;
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
}
|
||||
@@ -454,18 +459,33 @@ 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)?;
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||
@@ -477,8 +497,9 @@ impl ProviderService {
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
}
|
||||
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
// 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 MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
@@ -531,6 +552,88 @@ 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();
|
||||
@@ -629,15 +732,19 @@ impl ProviderService {
|
||||
Ok(cleaned.trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract common config for Gemini (JSON format)
|
||||
/// Extract common config for Gemini (ENV 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 snippet = serde_json::Map::new();
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
if let Some(env) = env {
|
||||
for (key, value) in env {
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||
@@ -648,17 +755,22 @@ impl ProviderService {
|
||||
};
|
||||
let trimmed = v.trim();
|
||||
if !trimmed.is_empty() {
|
||||
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
|
||||
// Skip values containing newlines to prevent ENV format injection
|
||||
if trimmed.contains('\n') || trimmed.contains('\r') {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{key}={trimmed}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snippet.is_empty() {
|
||||
return Ok("{}".to_string());
|
||||
if lines.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&Value::Object(snippet))
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
// Sort for consistent output
|
||||
lines.sort();
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenCode (JSON format)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
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;
|
||||
@@ -1232,7 +1233,27 @@ impl ProxyService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
write_live_snapshot(app_type, provider)
|
||||
// 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)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
@@ -1485,26 +1506,50 @@ 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: settings_config 直接作为备份
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
// Claude: 使用合并后的 final config 作为备份
|
||||
serde_json::to_string(&final_config)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
|
||||
}
|
||||
"codex" => {
|
||||
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
// Codex: 使用合并后的 final config
|
||||
serde_json::to_string(&final_config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
|
||||
}
|
||||
"gemini" => {
|
||||
// Gemini: 只提取 env 字段(与原始备份格式一致)
|
||||
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
|
||||
let env_backup = if let Some(env) = provider.settings_config.get("env") {
|
||||
let env_backup = if let Some(env) = final_config.get("env") {
|
||||
json!({ "env": env })
|
||||
} else {
|
||||
json!({ "env": {} })
|
||||
@@ -1520,7 +1565,7 @@ impl ProxyService {
|
||||
.await
|
||||
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
|
||||
|
||||
log::info!("已更新 {app_type} Live 备份(热切换)");
|
||||
log::info!("已更新 {app_type} Live 备份(热切换,含 common config 合并)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -53,15 +53,12 @@ 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,6 +22,10 @@ interface JsonEditorProps {
|
||||
language?: "json" | "javascript";
|
||||
height?: string | number;
|
||||
showMinimap?: boolean; // 添加此属性以防未来使用
|
||||
/** 只读模式 */
|
||||
readOnly?: boolean;
|
||||
/** 自动高度模式:根据内容自动调整高度,rows 作为最小行数 */
|
||||
autoHeight?: boolean;
|
||||
}
|
||||
|
||||
const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
@@ -33,6 +37,8 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
showValidation = true,
|
||||
language = "json",
|
||||
height,
|
||||
readOnly = false,
|
||||
autoHeight = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -82,7 +88,14 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// 创建编辑器扩展
|
||||
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
|
||||
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;
|
||||
|
||||
// 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
@@ -123,9 +136,17 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
? `${height}px`
|
||||
: height
|
||||
: undefined;
|
||||
|
||||
// 确定最终高度:优先级 height > autoHeight > minHeight
|
||||
const finalHeight = heightValue
|
||||
? heightValue
|
||||
: autoHeightPx
|
||||
? `${autoHeightPx}px`
|
||||
: undefined;
|
||||
|
||||
const sizingTheme = EditorView.theme({
|
||||
"&": heightValue
|
||||
? { height: heightValue }
|
||||
"&": finalHeight
|
||||
? { height: finalHeight }
|
||||
: { minHeight: `${minHeightPx}px` },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": {
|
||||
@@ -150,6 +171,22 @@ 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);
|
||||
@@ -208,7 +245,16 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||
}, [
|
||||
darkMode,
|
||||
rows,
|
||||
height,
|
||||
language,
|
||||
jsonLinter,
|
||||
readOnly,
|
||||
autoHeight,
|
||||
autoHeight ? value.split("\n").length : 0,
|
||||
]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建;autoHeight 模式下根据行数变化重建
|
||||
|
||||
// 当 value 从外部改变时更新编辑器内容
|
||||
useEffect(() => {
|
||||
@@ -261,7 +307,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
|
||||
className={isFullHeight ? "flex-1 min-h-0" : ""}
|
||||
/>
|
||||
{language === "json" && (
|
||||
{language === "json" && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -8,7 +9,13 @@ import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { providersApi, vscodeApi, type AppId } from "@/lib/api";
|
||||
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";
|
||||
|
||||
interface EditProviderDialogProps {
|
||||
open: boolean;
|
||||
@@ -81,7 +88,103 @@ export function EditProviderDialog({
|
||||
appId,
|
||||
)) as Record<string, unknown>;
|
||||
if (!cancelled && live && typeof live === "object") {
|
||||
setLiveSettings(live);
|
||||
// 检查是否启用了通用配置
|
||||
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);
|
||||
}
|
||||
setHasLoadedLive(true);
|
||||
}
|
||||
} catch {
|
||||
@@ -105,7 +208,14 @@ export function EditProviderDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
|
||||
}, [
|
||||
open,
|
||||
provider?.id,
|
||||
provider?.meta,
|
||||
appId,
|
||||
hasLoadedLive,
|
||||
isProxyTakeover,
|
||||
]); // 添加 provider?.meta 依赖
|
||||
|
||||
const initialSettingsConfig = useMemo(() => {
|
||||
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
||||
|
||||
@@ -30,6 +30,9 @@ interface CodexConfigEditorProps {
|
||||
onExtract?: () => void;
|
||||
|
||||
isExtracting?: boolean;
|
||||
|
||||
/** 最终合并后的配置(只读预览) */
|
||||
finalConfig?: string;
|
||||
}
|
||||
|
||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
@@ -47,6 +50,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
configError,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
finalConfig,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
@@ -76,6 +80,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||
commonConfigError={commonConfigError}
|
||||
configError={configError}
|
||||
finalConfig={finalConfig}
|
||||
/>
|
||||
|
||||
{/* Common Config Modal */}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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;
|
||||
@@ -19,22 +22,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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 isDarkMode = useDarkMode();
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
@@ -57,7 +45,8 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
onChange={handleChange}
|
||||
placeholder={t("codexConfig.authJsonPlaceholder")}
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
rows={3}
|
||||
autoHeight={true}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
@@ -83,6 +72,8 @@ interface CodexConfigSectionProps {
|
||||
onEditCommonConfig: () => void;
|
||||
commonConfigError?: string;
|
||||
configError?: string;
|
||||
/** 最终合并后的配置(只读预览) */
|
||||
finalConfig?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,24 +87,18 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
onEditCommonConfig,
|
||||
commonConfigError,
|
||||
configError,
|
||||
finalConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const isDarkMode = useDarkMode();
|
||||
const [showPreview, setShowPreview] = 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();
|
||||
}, []);
|
||||
if (useCommonConfig && finalConfig) {
|
||||
setShowPreview(true);
|
||||
}
|
||||
}, [useCommonConfig, finalConfig]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -136,7 +121,32 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditCommonConfig}
|
||||
@@ -152,15 +162,58 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder=""
|
||||
darkMode={isDarkMode}
|
||||
rows={8}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
{/* 自定义配置编辑器 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{configError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||
|
||||
@@ -3,8 +3,9 @@ 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 } from "lucide-react";
|
||||
import { Save, Download, Loader2, Eye, EyeOff } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
|
||||
interface CommonConfigEditorProps {
|
||||
value: string;
|
||||
@@ -19,6 +20,8 @@ interface CommonConfigEditorProps {
|
||||
onModalClose: () => void;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
/** 最终合并后的配置(只读预览) */
|
||||
finalConfig?: string;
|
||||
}
|
||||
|
||||
export function CommonConfigEditor({
|
||||
@@ -34,24 +37,18 @@ export function CommonConfigEditor({
|
||||
onModalClose,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
finalConfig,
|
||||
}: CommonConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const isDarkMode = useDarkMode();
|
||||
const [showPreview, setShowPreview] = 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();
|
||||
}, []);
|
||||
if (useCommonConfig && finalConfig) {
|
||||
setShowPreview(true);
|
||||
}
|
||||
}, [useCommonConfig, finalConfig]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -75,7 +72,32 @@ export function CommonConfigEditor({
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditClick}
|
||||
@@ -91,20 +113,64 @@ export function CommonConfigEditor({
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`{
|
||||
|
||||
{/* 自定义配置编辑器 */}
|
||||
<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={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={14}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FullScreenPanel
|
||||
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -88,13 +89,14 @@ export const GeminiCommonConfigModal: React.FC<
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`{
|
||||
"GEMINI_MODEL": "gemini-3-pro-preview"
|
||||
}`}
|
||||
placeholder={`# Gemini 通用配置
|
||||
# 格式: KEY=VALUE
|
||||
|
||||
GEMINI_MODEL=gemini-2.5-pro`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -17,6 +17,8 @@ interface GeminiConfigEditorProps {
|
||||
configError: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
/** 最终合并后的 env 配置(只读预览) */
|
||||
finalEnv?: string;
|
||||
}
|
||||
|
||||
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
@@ -34,6 +36,7 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
configError,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
finalEnv,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
@@ -56,6 +59,7 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
onCommonConfigToggle={onCommonConfigToggle}
|
||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||
commonConfigError={commonConfigError}
|
||||
finalEnv={finalEnv}
|
||||
/>
|
||||
|
||||
{/* Config JSON Section */}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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;
|
||||
@@ -11,6 +14,8 @@ interface GeminiEnvSectionProps {
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
onEditCommonConfig: () => void;
|
||||
commonConfigError?: string;
|
||||
/** 最终合并后的 env 配置(只读预览) */
|
||||
finalEnv?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,24 +30,18 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
onCommonConfigToggle,
|
||||
onEditCommonConfig,
|
||||
commonConfigError,
|
||||
finalEnv,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const isDarkMode = useDarkMode();
|
||||
const [showPreview, setShowPreview] = 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();
|
||||
}, []);
|
||||
if (useCommonConfig && finalEnv) {
|
||||
setShowPreview(true);
|
||||
}
|
||||
}, [useCommonConfig, finalEnv]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
@@ -74,7 +73,32 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditCommonConfig}
|
||||
@@ -92,17 +116,60 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
||||
{/* 自定义配置编辑器 */}
|
||||
<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/
|
||||
GEMINI_API_KEY=sk-your-api-key-here
|
||||
GEMINI_MODEL=gemini-3-pro-preview`}
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
@@ -134,22 +201,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||
configError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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 isDarkMode = useDarkMode();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -7,12 +7,13 @@ 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, type AppId } from "@/lib/api";
|
||||
import { providersApi, configApi, type AppId } from "@/lib/api";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ProviderMeta,
|
||||
ProviderTestConfig,
|
||||
ProviderProxyConfig,
|
||||
CommonConfigEnabledByApp,
|
||||
ClaudeApiFormat,
|
||||
OpenCodeModel,
|
||||
OpenCodeProviderConfig,
|
||||
@@ -38,6 +39,12 @@ 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";
|
||||
@@ -52,7 +59,6 @@ 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 {
|
||||
@@ -67,16 +73,49 @@ 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(
|
||||
@@ -407,8 +446,12 @@ export function ProviderForm({
|
||||
resetCodexConfig,
|
||||
} = useCodexConfigState({ initialData });
|
||||
|
||||
const { configError: codexConfigError, debouncedValidate } =
|
||||
useCodexTomlValidation();
|
||||
// 使用 Codex TOML 校验 hook (仅 Codex 模式)
|
||||
const {
|
||||
configError: codexConfigError,
|
||||
debouncedValidate,
|
||||
validateToml,
|
||||
} = useCodexTomlValidation();
|
||||
|
||||
const handleCodexConfigChange = useCallback(
|
||||
(value: string) => {
|
||||
@@ -484,6 +527,18 @@ 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,
|
||||
@@ -492,13 +547,11 @@ export function ProviderForm({
|
||||
handleCommonConfigSnippetChange,
|
||||
isExtracting: isClaudeExtracting,
|
||||
handleExtract: handleClaudeExtract,
|
||||
} = useCommonConfigSnippet({
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
initialData: appId === "claude" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
enabled: appId === "claude",
|
||||
});
|
||||
isLoading: isClaudeCommonConfigLoading,
|
||||
finalValue: claudeFinalConfig,
|
||||
getPendingCommonConfigSnippet: getPendingClaudeCommonConfigSnippet,
|
||||
markCommonConfigSaved: markClaudeCommonConfigSaved,
|
||||
} = claudeCommonConfig;
|
||||
|
||||
const {
|
||||
useCommonConfig: useCodexCommonConfigFlag,
|
||||
@@ -508,11 +561,16 @@ 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 {
|
||||
@@ -535,52 +593,76 @@ 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);
|
||||
updateGeminiEnvField("GEMINI_API_KEY", key.trim());
|
||||
// 同步更新 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));
|
||||
},
|
||||
[originalHandleGeminiApiKeyChange, updateGeminiEnvField],
|
||||
[originalHandleGeminiApiKeyChange, form],
|
||||
);
|
||||
|
||||
const handleGeminiBaseUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
originalHandleGeminiBaseUrlChange(url);
|
||||
updateGeminiEnvField(
|
||||
"GOOGLE_GEMINI_BASE_URL",
|
||||
url.trim().replace(/\/+$/, ""),
|
||||
);
|
||||
// 同步更新 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));
|
||||
},
|
||||
[originalHandleGeminiBaseUrlChange, updateGeminiEnvField],
|
||||
[originalHandleGeminiBaseUrlChange, form],
|
||||
);
|
||||
|
||||
const handleGeminiModelChange = useCallback(
|
||||
(model: string) => {
|
||||
originalHandleGeminiModelChange(model);
|
||||
updateGeminiEnvField("GEMINI_MODEL", model.trim());
|
||||
// 同步更新 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));
|
||||
},
|
||||
[originalHandleGeminiModelChange, updateGeminiEnvField],
|
||||
[originalHandleGeminiModelChange, form],
|
||||
);
|
||||
|
||||
// 使用 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,
|
||||
@@ -589,14 +671,11 @@ export function ProviderForm({
|
||||
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
|
||||
isExtracting: isGeminiExtracting,
|
||||
handleExtract: handleGeminiExtract,
|
||||
} = useGeminiCommonConfig({
|
||||
envValue: geminiEnv,
|
||||
onEnvChange: handleGeminiEnvChange,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
isLoading: isGeminiCommonConfigLoading,
|
||||
finalValue: geminiFinalEnv,
|
||||
getPendingCommonConfigSnippet: getPendingGeminiCommonConfigSnippet,
|
||||
markCommonConfigSaved: markGeminiCommonConfigSaved,
|
||||
} = geminiCommonConfig;
|
||||
|
||||
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
|
||||
const existingOpencodeKeys = useMemo(() => {
|
||||
@@ -904,6 +983,13 @@ 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;
|
||||
});
|
||||
@@ -987,6 +1073,21 @@ 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 {
|
||||
@@ -1071,7 +1172,63 @@ export function ProviderForm({
|
||||
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
const handleSubmit = (values: ProviderFormData) => {
|
||||
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 模式)
|
||||
if (appId === "claude" && templateValueEntries.length > 0) {
|
||||
const validation = validateTemplateValues();
|
||||
if (!validation.isValid && validation.missingField) {
|
||||
@@ -1169,14 +1326,56 @@ 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: codexConfig ?? "",
|
||||
config: configToSave,
|
||||
};
|
||||
settingsConfig = JSON.stringify(configObj);
|
||||
} catch (err) {
|
||||
@@ -1184,8 +1383,29 @@ export function ProviderForm({
|
||||
}
|
||||
} else if (appId === "gemini") {
|
||||
try {
|
||||
const envObj = envStringToObj(geminiEnv);
|
||||
let 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,
|
||||
@@ -1196,7 +1416,6 @@ 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;
|
||||
}
|
||||
@@ -1229,7 +1448,28 @@ export function ProviderForm({
|
||||
}
|
||||
settingsConfig = JSON.stringify(omoConfig);
|
||||
} else {
|
||||
settingsConfig = values.settingsConfig.trim();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
const payload: ProviderFormValues = {
|
||||
@@ -1307,6 +1547,16 @@ 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,
|
||||
@@ -1319,6 +1569,10 @@ export function ProviderForm({
|
||||
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
|
||||
? pricingConfig.pricingModelSource
|
||||
: undefined,
|
||||
...(shouldPersistCommonConfigEnabledByApp
|
||||
? { commonConfigEnabledByApp }
|
||||
: {}),
|
||||
// Claude API 格式(仅非官方 Claude 供应商使用)
|
||||
apiFormat:
|
||||
appId === "claude" && category !== "official"
|
||||
? localApiFormat
|
||||
@@ -1768,6 +2022,7 @@ export function ProviderForm({
|
||||
configError={codexConfigError}
|
||||
onExtract={handleCodexExtract}
|
||||
isExtracting={isCodexExtracting}
|
||||
finalConfig={codexFinalConfig}
|
||||
/>
|
||||
{settingsConfigErrorField}
|
||||
</>
|
||||
@@ -1789,6 +2044,7 @@ export function ProviderForm({
|
||||
configError={geminiConfigError}
|
||||
onExtract={handleGeminiExtract}
|
||||
isExtracting={isGeminiExtracting}
|
||||
finalEnv={envObjToString(geminiFinalEnv)}
|
||||
/>
|
||||
{settingsConfigErrorField}
|
||||
</>
|
||||
@@ -1843,6 +2099,7 @@ 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";
|
||||
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
|
||||
|
||||
// Common Config Hooks
|
||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||
|
||||
@@ -1,308 +1,195 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
updateTomlCommonConfigSnippet,
|
||||
hasTomlCommonConfigSnippet,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { toast } from "sonner";
|
||||
import { configApi } from "@/lib/api";
|
||||
|
||||
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`;
|
||||
import {
|
||||
useCommonConfigBase,
|
||||
type UseCommonConfigBaseReturn,
|
||||
} from "@/hooks/useCommonConfigBase";
|
||||
import {
|
||||
codexAdapter,
|
||||
preserveCodexConfigFormat,
|
||||
} from "@/hooks/commonConfigAdapters";
|
||||
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
||||
import type { ProviderMeta } from "@/types";
|
||||
|
||||
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 通用配置片段 (TOML 格式)
|
||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
||||
* 管理 Codex 通用配置片段
|
||||
*
|
||||
* 基于 useCommonConfigBase 泛型 Hook + Codex TOML 适配器实现。
|
||||
* 额外处理 Codex 双格式(纯 TOML / JSON wrapper)的写回逻辑。
|
||||
*/
|
||||
export function useCodexCommonConfig({
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
}: UseCodexCommonConfigProps) {
|
||||
}: UseCodexCommonConfigProps): UseCodexCommonConfigReturn {
|
||||
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);
|
||||
|
||||
// 用于跟踪是否正在通过通用配置更新
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
// 额外的 isExtracting 状态(base hook 的 handleExtract 不适用于 Codex)
|
||||
const [localIsExtracting, setLocalIsExtracting] = useState(false);
|
||||
const [localExtractError, setLocalExtractError] = useState("");
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
const base: UseCommonConfigBaseReturn<string> = useCommonConfigBase({
|
||||
adapter: codexAdapter,
|
||||
inputValue: codexConfig,
|
||||
onInputChange: onConfigChange,
|
||||
initialData,
|
||||
commonConfigSnippet,
|
||||
isLoading,
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
]);
|
||||
selectedPresetId,
|
||||
});
|
||||
|
||||
// 处理通用配置开关
|
||||
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]);
|
||||
|
||||
// 从编辑器当前内容提取通用配置片段
|
||||
// Codex 自定义 handleExtract:处理双格式写回 + 状态管理
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
setLocalIsExtracting(true);
|
||||
setLocalExtractError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("codex", {
|
||||
settingsConfig: JSON.stringify({
|
||||
config: codexConfig ?? "",
|
||||
}),
|
||||
});
|
||||
const request = codexAdapter.buildExtractRequest(base.finalValue);
|
||||
const extracted = await configApi.extractCommonConfigSnippet(
|
||||
"codex",
|
||||
request,
|
||||
);
|
||||
|
||||
if (!extracted || !extracted.trim()) {
|
||||
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
|
||||
setLocalExtractError(t("codexConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态
|
||||
setCommonConfigSnippetState(extracted);
|
||||
// 验证 TOML 格式
|
||||
const parseResult = codexAdapter.parseSnippet(extracted);
|
||||
if (parseResult.error || parseResult.config === null) {
|
||||
setLocalExtractError(
|
||||
t("codexConfig.extractedTomlInvalid", {
|
||||
defaultValue: "提取的配置 TOML 格式错误",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存到后端
|
||||
await configApi.setCommonConfigSnippet("codex", 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: "已提取通用配置,点击保存按钮完成保存",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("提取 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.extractFailed", { error: String(error) }),
|
||||
setLocalExtractError(
|
||||
t("codexConfig.extractFailed", {
|
||||
error: String(error),
|
||||
defaultValue: "提取失败",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
setLocalIsExtracting(false);
|
||||
}
|
||||
}, [codexConfig, t]);
|
||||
}, [base, codexConfig, onConfigChange, t]);
|
||||
|
||||
// 合并 error:优先显示 extract 错误,其次是 base 的错误
|
||||
const combinedError = localExtractError || base.commonConfigError;
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
useCommonConfig: base.useCommonConfig,
|
||||
commonConfigSnippet: base.commonConfigSnippet,
|
||||
commonConfigError: combinedError,
|
||||
isLoading: base.isLoading,
|
||||
isExtracting: localIsExtracting,
|
||||
handleCommonConfigToggle: base.handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
finalConfig: base.finalValue,
|
||||
hasUnsavedCommonConfig: base.hasUnsavedCommonConfig,
|
||||
getPendingCommonConfigSnippet: base.getPendingCommonConfigSnippet,
|
||||
markCommonConfigSaved: base.markCommonConfigSaved,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
extractCodexBaseUrl,
|
||||
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||
@@ -18,6 +19,7 @@ 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("");
|
||||
@@ -109,18 +111,21 @@ 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 "Auth JSON must be an object";
|
||||
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");
|
||||
}
|
||||
return "";
|
||||
} catch {
|
||||
return "Invalid JSON format";
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// 设置 auth 并验证
|
||||
const setCodexAuth = useCallback(
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
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,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface UseGeminiConfigStateProps {
|
||||
initialData?: {
|
||||
@@ -13,6 +14,7 @@ interface UseGeminiConfigStateProps {
|
||||
export function useGeminiConfigState({
|
||||
initialData,
|
||||
}: UseGeminiConfigStateProps) {
|
||||
const { t } = useTranslation();
|
||||
const [geminiEnv, setGeminiEnvState] = useState("");
|
||||
const [geminiConfig, setGeminiConfigState] = useState("");
|
||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||
@@ -119,18 +121,21 @@ 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 "";
|
||||
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");
|
||||
}
|
||||
return "Config must be a JSON object";
|
||||
} catch {
|
||||
return "Invalid JSON format";
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// 设置 env
|
||||
const setGeminiEnv = useCallback((value: string) => {
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* 通用配置格式适配器
|
||||
*
|
||||
* 提供 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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* 通用配置管理基础 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
@@ -37,7 +37,8 @@
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"actions": "Actions",
|
||||
"deleting": "Deleting..."
|
||||
"deleting": "Deleting...",
|
||||
"readonly": "Read-only"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "Enter API Key",
|
||||
@@ -54,11 +55,18 @@
|
||||
"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}}",
|
||||
"saveFailed": "Save 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"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "View on GitHub",
|
||||
@@ -538,7 +546,10 @@
|
||||
"categoryOfficial": "Official",
|
||||
"categoryCnOfficial": "Opensource Official",
|
||||
"categoryAggregation": "Aggregation",
|
||||
"categoryThirdParty": "Third Party"
|
||||
"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"
|
||||
},
|
||||
"endpointTest": {
|
||||
"title": "API Endpoint Management",
|
||||
@@ -603,10 +614,12 @@
|
||||
"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": {
|
||||
@@ -621,14 +634,17 @@
|
||||
"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}}"
|
||||
"configReplaceFailed": "Config replace failed: {{error}}",
|
||||
"forbiddenKeysWarning": "The following keys were filtered (not allowed in common config): {{keys}}"
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "API Format",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"search": "検索",
|
||||
"reset": "リセット",
|
||||
"actions": "操作",
|
||||
"deleting": "削除中..."
|
||||
"deleting": "削除中...",
|
||||
"readonly": "読み取り専用"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "API Key を入力",
|
||||
@@ -54,11 +55,18 @@
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "共通設定スニペットを編集",
|
||||
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
|
||||
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
||||
"fullSettingsHint": "Claude Code の settings.json 全文",
|
||||
"extractFromCurrent": "編集内容から抽出",
|
||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
"extractSuccessNeedSave": "共通設定を抽出しました。保存ボタンをクリックして完了してください",
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"hidePreview": "マージプレビューを非表示",
|
||||
"showPreview": "マージプレビューを表示",
|
||||
"customConfig": "カスタム設定(共通設定を上書き)",
|
||||
"mergedPreview": "マージプレビュー(読み取り専用)",
|
||||
"mergedPreviewHint": "共通設定 + カスタム設定 = 最終設定"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "GitHub で見る",
|
||||
@@ -538,7 +546,10 @@
|
||||
"categoryOfficial": "公式",
|
||||
"categoryCnOfficial": "オープンソース公式",
|
||||
"categoryAggregation": "アグリゲーター",
|
||||
"categoryThirdParty": "サードパーティ"
|
||||
"categoryThirdParty": "サードパーティ",
|
||||
"commonConfigLoading": "共通設定を読み込み中です。保存する前にお待ちください",
|
||||
"commonConfigSyncFailed": "共通設定の同期に失敗しました。再試行してください",
|
||||
"saveCommonConfigFailed": "共通設定の保存に失敗しました"
|
||||
},
|
||||
"endpointTest": {
|
||||
"title": "API エンドポイント管理",
|
||||
@@ -603,10 +614,12 @@
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
|
||||
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
|
||||
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
||||
"apiUrlLabel": "API リクエスト URL",
|
||||
"extractFromCurrent": "編集内容から抽出",
|
||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||
"extractSuccessNeedSave": "共通設定を抽出しました。保存ボタンをクリックして完了してください",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
},
|
||||
"geminiConfig": {
|
||||
@@ -621,14 +634,17 @@
|
||||
"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}}"
|
||||
"configReplaceFailed": "設定の置換に失敗しました: {{error}}",
|
||||
"forbiddenKeysWarning": "以下のキーはフィルタされました(共通設定では許可されていません):{{keys}}"
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "API フォーマット",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"search": "查询",
|
||||
"reset": "重置",
|
||||
"actions": "操作",
|
||||
"deleting": "删除中..."
|
||||
"deleting": "删除中...",
|
||||
"readonly": "只读"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "请输入API Key",
|
||||
@@ -54,11 +55,18 @@
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "编辑通用配置片段",
|
||||
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
|
||||
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
||||
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
|
||||
"extractFromCurrent": "从编辑内容提取",
|
||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||
"extractFailed": "提取失败: {{error}}",
|
||||
"saveFailed": "保存失败: {{error}}"
|
||||
"extractSuccessNeedSave": "已提取通用配置,点击保存按钮完成保存",
|
||||
"saveFailed": "保存失败: {{error}}",
|
||||
"hidePreview": "隐藏合并预览",
|
||||
"showPreview": "显示合并预览",
|
||||
"customConfig": "自定义配置(覆盖通用配置)",
|
||||
"mergedPreview": "合并预览(只读)",
|
||||
"mergedPreviewHint": "通用配置 + 自定义配置 = 最终配置"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "在 GitHub 上查看",
|
||||
@@ -538,7 +546,10 @@
|
||||
"categoryOfficial": "官方",
|
||||
"categoryCnOfficial": "开源官方",
|
||||
"categoryAggregation": "聚合服务",
|
||||
"categoryThirdParty": "第三方"
|
||||
"categoryThirdParty": "第三方",
|
||||
"commonConfigLoading": "通用配置正在加载中,请稍候再保存",
|
||||
"commonConfigSyncFailed": "通用配置同步部分失败,请重试",
|
||||
"saveCommonConfigFailed": "保存通用配置失败"
|
||||
},
|
||||
"endpointTest": {
|
||||
"title": "请求地址管理",
|
||||
@@ -603,10 +614,12 @@
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
|
||||
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
|
||||
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
||||
"apiUrlLabel": "API 请求地址",
|
||||
"extractFromCurrent": "从编辑内容提取",
|
||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||
"extractFailed": "提取失败: {{error}}",
|
||||
"extractSuccessNeedSave": "已提取通用配置,点击保存按钮完成保存",
|
||||
"saveFailed": "保存失败: {{error}}"
|
||||
},
|
||||
"geminiConfig": {
|
||||
@@ -621,14 +634,17 @@
|
||||
"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}}"
|
||||
"configReplaceFailed": "配置替换失败: {{error}}",
|
||||
"forbiddenKeysWarning": "以下密钥已被自动过滤(禁止在通用配置中设置):{{keys}}"
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "接口格式",
|
||||
|
||||
+477
-22
@@ -1,28 +1,70 @@
|
||||
// 配置相关 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 = "claude" | "codex" | "gemini" | "omo";
|
||||
export type AppType = CommonConfigAppType | "omo";
|
||||
type SyncAppType = CommonConfigAppType;
|
||||
|
||||
/**
|
||||
* 获取 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 interface SyncResult {
|
||||
/** 成功更新的供应商数量 */
|
||||
count: number;
|
||||
/** 错误信息(如果有) */
|
||||
error?: string;
|
||||
/** 是否已排队等待执行(debounce 中) */
|
||||
queued?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 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 });
|
||||
}
|
||||
/** 同步结果回调类型 */
|
||||
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;
|
||||
|
||||
/**
|
||||
* 获取通用配置片段(统一接口)
|
||||
@@ -39,7 +81,10 @@ export async function getCommonConfigSnippet(
|
||||
* 设置通用配置片段(统一接口)
|
||||
* @param appType - 应用类型(claude/codex/gemini)
|
||||
* @param snippet - 通用配置片段(原始字符串)
|
||||
* @throws 如果格式无效(Claude/Gemini 验证 JSON,Codex 暂不验证)
|
||||
* @throws 如果格式无效
|
||||
* - Claude: 验证 JSON 格式
|
||||
* - Gemini: 验证 ENV/JSON 格式,拒绝包含禁用键(GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL)
|
||||
* - Codex: TOML 格式,暂不验证
|
||||
*/
|
||||
export async function setCommonConfigSnippet(
|
||||
appType: AppType,
|
||||
@@ -56,14 +101,14 @@ export async function setCommonConfigSnippet(
|
||||
*
|
||||
* @param appType - 应用类型(claude/codex/gemini)
|
||||
* @param options - 可选:提取来源
|
||||
* @returns 提取的通用配置片段(JSON/TOML 字符串)
|
||||
* @returns 提取的通用配置片段(Claude: JSON, Codex: TOML, Gemini: ENV)
|
||||
*/
|
||||
export type ExtractCommonConfigSnippetOptions = {
|
||||
settingsConfig?: string;
|
||||
};
|
||||
|
||||
export async function extractCommonConfigSnippet(
|
||||
appType: Exclude<AppType, "omo">,
|
||||
appType: CommonConfigAppType,
|
||||
options?: ExtractCommonConfigSnippetOptions,
|
||||
): Promise<string> {
|
||||
const args: Record<string, unknown> = { appType };
|
||||
@@ -75,3 +120,413 @@ 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,6 +120,13 @@ export interface ProviderProxyConfig {
|
||||
proxyPassword?: string;
|
||||
}
|
||||
|
||||
export type CommonConfigEnabledByApp = Partial<{
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
}>;
|
||||
|
||||
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
||||
export interface ProviderMeta {
|
||||
// 自定义端点:以 URL 为键,值为端点信息
|
||||
@@ -140,6 +147,10 @@ export interface ProviderMeta {
|
||||
costMultiplier?: string;
|
||||
// 供应商计费模式来源
|
||||
pricingModelSource?: string;
|
||||
// 是否启用通用配置片段(用于跨供应商保持勾选状态)
|
||||
commonConfigEnabled?: boolean;
|
||||
// 按应用记录通用配置启用状态(优先于 commonConfigEnabled)
|
||||
commonConfigEnabledByApp?: CommonConfigEnabledByApp;
|
||||
// Claude API 格式(仅 Claude 供应商使用)
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 通用配置内容检测工具
|
||||
*
|
||||
* 这些是纯函数,不依赖 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;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 配置合并工具函数
|
||||
*
|
||||
* 用于公共配置重构后的运行时合并逻辑:
|
||||
* - 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 };
|
||||
};
|
||||
+298
-221
@@ -2,86 +2,15 @@
|
||||
|
||||
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||||
import { normalizeQuotes } from "@/utils/textNormalization";
|
||||
import { isPlainObject } from "@/utils/configMerge";
|
||||
|
||||
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;
|
||||
}
|
||||
// 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];
|
||||
|
||||
// 验证JSON配置格式
|
||||
export const validateJsonConfig = (
|
||||
@@ -102,69 +31,6 @@ 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,
|
||||
@@ -329,85 +195,6 @@ 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(支持单/双引号)
|
||||
@@ -530,3 +317,293 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 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!);
|
||||
};
|
||||
@@ -450,17 +450,18 @@ 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,
|
||||
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);
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user