mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"indexmap 2.11.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned 0.6.9",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.11",
|
"toml_datetime 0.6.11",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = { version = "0.8", features = ["preserve_order"] }
|
||||||
toml_edit = "0.22"
|
toml_edit = "0.22"
|
||||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||||
|
|||||||
@@ -339,6 +339,20 @@ pub struct CommonConfigSnippets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||||
match app {
|
match app {
|
||||||
@@ -378,7 +392,9 @@ pub struct MultiAppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skills: SkillStore,
|
pub skills: SkillStore,
|
||||||
/// 通用配置片段(按应用分治)
|
/// 通用配置片段(按应用分治)
|
||||||
#[serde(default)]
|
/// 注意:此字段主要用于从旧版 config.json 迁移数据到数据库
|
||||||
|
/// 迁移成功后会被清空,空时不写入 config.json
|
||||||
|
#[serde(default, skip_serializing_if = "CommonConfigSnippets::is_empty")]
|
||||||
pub common_config_snippets: CommonConfigSnippets,
|
pub common_config_snippets: CommonConfigSnippets,
|
||||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -204,11 +204,36 @@ pub async fn set_common_config_snippet(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !snippet.trim().is_empty() {
|
if !snippet.trim().is_empty() {
|
||||||
match app_type.as_str() {
|
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)
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
.map_err(invalid_json_format_error)?;
|
.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
|
/// 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> {
|
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||||
// Check if any usage script fields are provided
|
// Check if any usage script fields are provided
|
||||||
if request.usage_script.is_none()
|
let has_usage_fields = request.usage_script.is_some()
|
||||||
&& request.usage_enabled.is_none()
|
|| request.usage_enabled.is_some()
|
||||||
&& request.usage_api_key.is_none()
|
|| request.usage_api_key.is_some()
|
||||||
&& request.usage_base_url.is_none()
|
|| request.usage_base_url.is_some()
|
||||||
&& request.usage_access_token.is_none()
|
|| request.usage_access_token.is_some()
|
||||||
&& request.usage_user_id.is_none()
|
|| request.usage_user_id.is_some()
|
||||||
&& request.usage_auto_interval.is_none()
|
|| request.usage_auto_interval.is_some();
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode usage script code if provided
|
// Build usage script if fields are provided
|
||||||
let code = if let Some(script_b64) = &request.usage_script {
|
let usage_script = if has_usage_fields {
|
||||||
let decoded = decode_base64_param("usage_script", script_b64)?;
|
// Decode usage script code if provided
|
||||||
String::from_utf8(decoded)
|
let code = if let Some(script_b64) = &request.usage_script {
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
|
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 {
|
} else {
|
||||||
String::new()
|
None
|
||||||
};
|
|
||||||
|
|
||||||
// Determine enabled state: explicit param > has code > false
|
|
||||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
|
||||||
|
|
||||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
|
||||||
// Note: use primary endpoint only (first one if comma-separated)
|
|
||||||
let usage_script = UsageScript {
|
|
||||||
enabled,
|
|
||||||
language: "javascript".to_string(),
|
|
||||||
code,
|
|
||||||
timeout: Some(10),
|
|
||||||
api_key: request
|
|
||||||
.usage_api_key
|
|
||||||
.clone()
|
|
||||||
.or_else(|| request.api_key.clone()),
|
|
||||||
base_url: request.usage_base_url.clone().or_else(|| {
|
|
||||||
let primary = get_primary_endpoint(request);
|
|
||||||
if primary.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(primary)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
access_token: request.usage_access_token.clone(),
|
|
||||||
user_id: request.usage_user_id.clone(),
|
|
||||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
|
||||||
auto_query_interval: request.usage_auto_interval,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Always return a ProviderMeta with common_config_enabled = false for deeplink imports
|
||||||
|
// This ensures the imported provider uses its own settings_config directly
|
||||||
|
// without merging with the global common config snippet
|
||||||
Ok(Some(ProviderMeta {
|
Ok(Some(ProviderMeta {
|
||||||
usage_script: Some(usage_script),
|
usage_script,
|
||||||
|
common_config_enabled: Some(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod claude_plugin;
|
|||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod config_merge;
|
||||||
mod database;
|
mod database;
|
||||||
mod deeplink;
|
mod deeplink;
|
||||||
mod error;
|
mod error;
|
||||||
|
|||||||
@@ -191,6 +191,19 @@ pub struct ProviderProxyConfig {
|
|||||||
pub proxy_password: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderMeta {
|
pub struct ProviderMeta {
|
||||||
@@ -230,6 +243,18 @@ pub struct ProviderMeta {
|
|||||||
/// 供应商单独的代理配置
|
/// 供应商单独的代理配置
|
||||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||||
pub proxy_config: Option<ProviderProxyConfig>,
|
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 供应商使用)
|
/// Claude API 格式(仅 Claude 供应商使用)
|
||||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||||
@@ -631,7 +656,7 @@ pub struct OpenCodeModelLimit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod provider_tests {
|
||||||
use super::{
|
use super::{
|
||||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
||||||
ProviderManager, ProviderMeta, UniversalProvider,
|
ProviderManager, ProviderMeta, UniversalProvider,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::config::write_json_file;
|
|||||||
use crate::database::OmoGlobalConfig;
|
use crate::database::OmoGlobalConfig;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::opencode_config::get_opencode_dir;
|
use crate::opencode_config::get_opencode_dir;
|
||||||
|
use crate::provider::{CommonConfigEnabledByApp, ProviderMeta};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
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> {
|
pub fn delete_config_file() -> Result<(), AppError> {
|
||||||
let config_path = Self::config_path();
|
let config_path = Self::config_path();
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
@@ -160,11 +184,7 @@ impl OmoService {
|
|||||||
let agents = p.settings_config.get("agents").cloned();
|
let agents = p.settings_config.get("agents").cloned();
|
||||||
let categories = p.settings_config.get("categories").cloned();
|
let categories = p.settings_config.get("categories").cloned();
|
||||||
let other_fields = p.settings_config.get("otherFields").cloned();
|
let other_fields = p.settings_config.get("otherFields").cloned();
|
||||||
let use_common_config = p
|
let use_common_config = Self::resolve_common_config_enabled(p);
|
||||||
.settings_config
|
|
||||||
.get("useCommonConfig")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(true);
|
|
||||||
(agents, categories, other_fields, use_common_config)
|
(agents, categories, other_fields, use_common_config)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,7 +257,6 @@ impl OmoService {
|
|||||||
if let Some(categories) = obj.get("categories") {
|
if let Some(categories) = obj.get("categories") {
|
||||||
settings.insert("categories".to_string(), categories.clone());
|
settings.insert("categories".to_string(), categories.clone());
|
||||||
}
|
}
|
||||||
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
|
|
||||||
|
|
||||||
let other = Self::extract_other_fields(&obj);
|
let other = Self::extract_other_fields(&obj);
|
||||||
if !other.is_empty() {
|
if !other.is_empty() {
|
||||||
@@ -263,7 +282,13 @@ impl OmoService {
|
|||||||
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
||||||
sort_index: None,
|
sort_index: None,
|
||||||
notes: 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: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
in_failover_queue: false,
|
in_failover_queue: false,
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
//! Live configuration operations
|
//! Live configuration operations
|
||||||
//!
|
//!
|
||||||
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
|
//! 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;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -9,6 +17,7 @@ use serde_json::{json, Value};
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
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::{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::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::services::mcp::McpService;
|
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> {
|
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 {
|
match app_type {
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
let path = get_claude_settings_path();
|
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)?;
|
write_json_file(&path, &settings)?;
|
||||||
}
|
}
|
||||||
AppType::Codex => {
|
AppType::Codex => {
|
||||||
let obj = provider
|
let obj = config_to_write.as_object().ok_or_else(|| {
|
||||||
.settings_config
|
AppError::Config(
|
||||||
.as_object()
|
"CODEX_CONFIG_NOT_OBJECT: settings_config must be a JSON object".to_string(),
|
||||||
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
)
|
||||||
let auth = obj
|
})?;
|
||||||
.get("auth")
|
let auth = obj.get("auth").ok_or_else(|| {
|
||||||
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
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(|| {
|
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();
|
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 => {
|
AppType::Gemini => {
|
||||||
// Delegate to write_gemini_live which handles env file writing correctly
|
// 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 => {
|
AppType::OpenCode => {
|
||||||
// OpenCode uses additive mode - write provider to config
|
// 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;
|
use crate::provider::OpenCodeProviderConfig;
|
||||||
|
|
||||||
// Defensive check: if settings_config is a full config structure, extract provider fragment
|
// 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)
|
// Detect full config structure (has $schema or top-level provider field)
|
||||||
if obj.contains_key("$schema") || obj.contains_key("provider") {
|
if obj.contains_key("$schema") || obj.contains_key("provider") {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
@@ -227,6 +303,8 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
|||||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||||
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
|
/// 这确保了配置导入后无效 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.
|
/// 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> {
|
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||||
// Sync providers based on mode
|
// 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())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
if let Some(provider) = providers.get(¤t_id) {
|
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,
|
// Note: get_effective_current_provider already validates existence,
|
||||||
// so providers.get() should always succeed here
|
// so providers.get() should always succeed here
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ use serde::Deserialize;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
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::error::AppError;
|
||||||
use crate::provider::{Provider, UsageResult};
|
use crate::provider::{Provider, UsageResult};
|
||||||
use crate::services::mcp::McpService;
|
use crate::services::mcp::McpService;
|
||||||
@@ -27,7 +30,7 @@ pub use live::{
|
|||||||
|
|
||||||
// Internal re-exports (pub(crate))
|
// Internal re-exports (pub(crate))
|
||||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
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
|
// Internal re-exports
|
||||||
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||||
@@ -181,7 +184,8 @@ impl ProviderService {
|
|||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
.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)
|
Ok(true)
|
||||||
@@ -241,7 +245,8 @@ impl ProviderService {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||||
} else {
|
} 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
|
// Sync MCP
|
||||||
McpService::sync_all_enabled(state)?;
|
McpService::sync_all_enabled(state)?;
|
||||||
}
|
}
|
||||||
@@ -454,18 +459,33 @@ impl ProviderService {
|
|||||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
// 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)?;
|
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||||
|
|
||||||
match (current_id, matches!(app_type, AppType::OpenCode)) {
|
if let Some(current_id) = current_id {
|
||||||
(Some(current_id), false) if current_id != id => {
|
if current_id != id {
|
||||||
// Only backfill when switching to a different provider.
|
// OpenCode uses additive mode - all providers coexist in the same file,
|
||||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
if !matches!(app_type, AppType::OpenCode) {
|
||||||
current_provider.settings_config = live_config;
|
// Only backfill when switching to a different provider
|
||||||
// Ignore backfill failure, don't affect switch flow.
|
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
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)
|
// 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)?;
|
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
// Sync to live (use write_live_snapshot_with_merge for common config runtime merge)
|
||||||
write_live_snapshot(&app_type, provider)?;
|
// Note: write_gemini_live handles security flag internally for Gemini
|
||||||
|
write_live_snapshot_with_merge(state, &app_type, provider)?;
|
||||||
|
|
||||||
// Sync MCP
|
// Sync MCP
|
||||||
McpService::sync_all_enabled(state)?;
|
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)
|
/// Extract common config for Claude (JSON format)
|
||||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
||||||
let mut config = settings.clone();
|
let mut config = settings.clone();
|
||||||
@@ -629,15 +732,19 @@ impl ProviderService {
|
|||||||
Ok(cleaned.trim().to_string())
|
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:
|
/// Extracts `.env` values while excluding provider-specific credentials:
|
||||||
/// - GOOGLE_GEMINI_BASE_URL
|
/// - GOOGLE_GEMINI_BASE_URL
|
||||||
/// - GEMINI_API_KEY
|
/// - 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> {
|
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
||||||
let env = settings.get("env").and_then(|v| v.as_object());
|
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 {
|
if let Some(env) = env {
|
||||||
for (key, value) in env {
|
for (key, value) in env {
|
||||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||||
@@ -648,17 +755,22 @@ impl ProviderService {
|
|||||||
};
|
};
|
||||||
let trimmed = v.trim();
|
let trimmed = v.trim();
|
||||||
if !trimmed.is_empty() {
|
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() {
|
if lines.is_empty() {
|
||||||
return Ok("{}".to_string());
|
return Ok(String::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
serde_json::to_string_pretty(&Value::Object(snippet))
|
// Sort for consistent output
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
lines.sort();
|
||||||
|
Ok(lines.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract common config for OpenCode (JSON format)
|
/// Extract common config for OpenCode (JSON format)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
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::database::Database;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::server::ProxyServer;
|
use crate::proxy::server::ProxyServer;
|
||||||
@@ -1232,7 +1233,27 @@ impl ProxyService {
|
|||||||
return Ok(false);
|
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}"))?;
|
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -1485,26 +1506,50 @@ impl ProxyService {
|
|||||||
///
|
///
|
||||||
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,
|
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,
|
||||||
/// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。
|
/// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。
|
||||||
|
///
|
||||||
|
/// **重要**: 新架构下 settings_config 存储的是自定义配置(custom diff),
|
||||||
|
/// 备份时需要先与 common config 合并,生成完整的 finalConfig。
|
||||||
pub async fn update_live_backup_from_provider(
|
pub async fn update_live_backup_from_provider(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
) -> Result<(), String> {
|
) -> 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 {
|
let backup_json = match app_type {
|
||||||
"claude" => {
|
"claude" => {
|
||||||
// Claude: settings_config 直接作为备份
|
// Claude: 使用合并后的 final config 作为备份
|
||||||
serde_json::to_string(&provider.settings_config)
|
serde_json::to_string(&final_config)
|
||||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
|
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
|
||||||
}
|
}
|
||||||
"codex" => {
|
"codex" => {
|
||||||
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
|
// Codex: 使用合并后的 final config
|
||||||
serde_json::to_string(&provider.settings_config)
|
serde_json::to_string(&final_config)
|
||||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
|
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
|
||||||
}
|
}
|
||||||
"gemini" => {
|
"gemini" => {
|
||||||
// Gemini: 只提取 env 字段(与原始备份格式一致)
|
// Gemini: 只提取 env 字段(与原始备份格式一致)
|
||||||
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
|
let env_backup = if let Some(env) = final_config.get("env") {
|
||||||
let env_backup = if let Some(env) = provider.settings_config.get("env") {
|
|
||||||
json!({ "env": env })
|
json!({ "env": env })
|
||||||
} else {
|
} else {
|
||||||
json!({ "env": {} })
|
json!({ "env": {} })
|
||||||
@@ -1520,7 +1565,7 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
|
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
|
||||||
|
|
||||||
log::info!("已更新 {app_type} Live 备份(热切换)");
|
log::info!("已更新 {app_type} Live 备份(热切换,含 common config 合并)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,12 @@ export function DeepLinkImportDialog() {
|
|||||||
const unlistenImport = listen<DeepLinkImportRequest>(
|
const unlistenImport = listen<DeepLinkImportRequest>(
|
||||||
"deeplink-import",
|
"deeplink-import",
|
||||||
async (event) => {
|
async (event) => {
|
||||||
console.log("Deep link import event received:", event.payload);
|
|
||||||
|
|
||||||
// If config is present, merge it to get the complete configuration
|
// If config is present, merge it to get the complete configuration
|
||||||
if (event.payload.config || event.payload.configUrl) {
|
if (event.payload.config || event.payload.configUrl) {
|
||||||
try {
|
try {
|
||||||
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
|
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
|
||||||
event.payload,
|
event.payload,
|
||||||
);
|
);
|
||||||
console.log("Config merged successfully:", mergedRequest);
|
|
||||||
setRequest(mergedRequest);
|
setRequest(mergedRequest);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to merge config:", error);
|
console.error("Failed to merge config:", error);
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ interface JsonEditorProps {
|
|||||||
language?: "json" | "javascript";
|
language?: "json" | "javascript";
|
||||||
height?: string | number;
|
height?: string | number;
|
||||||
showMinimap?: boolean; // 添加此属性以防未来使用
|
showMinimap?: boolean; // 添加此属性以防未来使用
|
||||||
|
/** 只读模式 */
|
||||||
|
readOnly?: boolean;
|
||||||
|
/** 自动高度模式:根据内容自动调整高度,rows 作为最小行数 */
|
||||||
|
autoHeight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsonEditor: React.FC<JsonEditorProps> = ({
|
const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||||
@@ -33,6 +37,8 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
showValidation = true,
|
showValidation = true,
|
||||||
language = "json",
|
language = "json",
|
||||||
height,
|
height,
|
||||||
|
readOnly = false,
|
||||||
|
autoHeight = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -82,7 +88,14 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
if (!editorRef.current) return;
|
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,但可以正确响应主题
|
// 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
@@ -123,9 +136,17 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
? `${height}px`
|
? `${height}px`
|
||||||
: height
|
: height
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// 确定最终高度:优先级 height > autoHeight > minHeight
|
||||||
|
const finalHeight = heightValue
|
||||||
|
? heightValue
|
||||||
|
: autoHeightPx
|
||||||
|
? `${autoHeightPx}px`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const sizingTheme = EditorView.theme({
|
const sizingTheme = EditorView.theme({
|
||||||
"&": heightValue
|
"&": finalHeight
|
||||||
? { height: heightValue }
|
? { height: finalHeight }
|
||||||
: { minHeight: `${minHeightPx}px` },
|
: { minHeight: `${minHeightPx}px` },
|
||||||
".cm-scroller": { overflow: "auto" },
|
".cm-scroller": { overflow: "auto" },
|
||||||
".cm-content": {
|
".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) {
|
if (darkMode) {
|
||||||
extensions.push(oneDark);
|
extensions.push(oneDark);
|
||||||
@@ -208,7 +245,16 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
view.destroy();
|
view.destroy();
|
||||||
viewRef.current = null;
|
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 从外部改变时更新编辑器内容
|
// 当 value 从外部改变时更新编辑器内容
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -261,7 +307,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
|
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
|
||||||
className={isFullHeight ? "flex-1 min-h-0" : ""}
|
className={isFullHeight ? "flex-1 min-h-0" : ""}
|
||||||
/>
|
/>
|
||||||
{language === "json" && (
|
{language === "json" && !readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleFormat}
|
onClick={handleFormat}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
@@ -8,7 +9,13 @@ import {
|
|||||||
ProviderForm,
|
ProviderForm,
|
||||||
type ProviderFormValues,
|
type ProviderFormValues,
|
||||||
} from "@/components/providers/forms/ProviderForm";
|
} 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 {
|
interface EditProviderDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -81,7 +88,103 @@ export function EditProviderDialog({
|
|||||||
appId,
|
appId,
|
||||||
)) as Record<string, unknown>;
|
)) as Record<string, unknown>;
|
||||||
if (!cancelled && live && typeof live === "object") {
|
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);
|
setHasLoadedLive(true);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -105,7 +208,14 @@ export function EditProviderDialog({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
|
}, [
|
||||||
|
open,
|
||||||
|
provider?.id,
|
||||||
|
provider?.meta,
|
||||||
|
appId,
|
||||||
|
hasLoadedLive,
|
||||||
|
isProxyTakeover,
|
||||||
|
]); // 添加 provider?.meta 依赖
|
||||||
|
|
||||||
const initialSettingsConfig = useMemo(() => {
|
const initialSettingsConfig = useMemo(() => {
|
||||||
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ interface CodexConfigEditorProps {
|
|||||||
onExtract?: () => void;
|
onExtract?: () => void;
|
||||||
|
|
||||||
isExtracting?: boolean;
|
isExtracting?: boolean;
|
||||||
|
|
||||||
|
/** 最终合并后的配置(只读预览) */
|
||||||
|
finalConfig?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||||
@@ -47,6 +50,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
configError,
|
configError,
|
||||||
onExtract,
|
onExtract,
|
||||||
isExtracting,
|
isExtracting,
|
||||||
|
finalConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -76,6 +80,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||||
commonConfigError={commonConfigError}
|
commonConfigError={commonConfigError}
|
||||||
configError={configError}
|
configError={configError}
|
||||||
|
finalConfig={finalConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Common Config Modal */}
|
{/* Common Config Modal */}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
import JsonEditor from "@/components/JsonEditor";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||||
|
|
||||||
interface CodexAuthSectionProps {
|
interface CodexAuthSectionProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -19,22 +22,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
|||||||
error,
|
error,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const isDarkMode = useDarkMode();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = (newValue: string) => {
|
const handleChange = (newValue: string) => {
|
||||||
onChange(newValue);
|
onChange(newValue);
|
||||||
@@ -57,7 +45,8 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={t("codexConfig.authJsonPlaceholder")}
|
placeholder={t("codexConfig.authJsonPlaceholder")}
|
||||||
darkMode={isDarkMode}
|
darkMode={isDarkMode}
|
||||||
rows={6}
|
rows={3}
|
||||||
|
autoHeight={true}
|
||||||
showValidation={true}
|
showValidation={true}
|
||||||
language="json"
|
language="json"
|
||||||
/>
|
/>
|
||||||
@@ -83,6 +72,8 @@ interface CodexConfigSectionProps {
|
|||||||
onEditCommonConfig: () => void;
|
onEditCommonConfig: () => void;
|
||||||
commonConfigError?: string;
|
commonConfigError?: string;
|
||||||
configError?: string;
|
configError?: string;
|
||||||
|
/** 最终合并后的配置(只读预览) */
|
||||||
|
finalConfig?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,24 +87,18 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
|||||||
onEditCommonConfig,
|
onEditCommonConfig,
|
||||||
commonConfigError,
|
commonConfigError,
|
||||||
configError,
|
configError,
|
||||||
|
finalConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const isDarkMode = useDarkMode();
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
|
||||||
|
// 当启用通用配置时,自动显示预览
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
if (useCommonConfig && finalConfig) {
|
||||||
|
setShowPreview(true);
|
||||||
const observer = new MutationObserver(() => {
|
}
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
}, [useCommonConfig, finalConfig]);
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -136,7 +121,32 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
|||||||
</label>
|
</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("codexConfig.hidePreview", {
|
||||||
|
defaultValue: "隐藏合并预览",
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
{t("codexConfig.showPreview", {
|
||||||
|
defaultValue: "显示合并预览",
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEditCommonConfig}
|
onClick={onEditCommonConfig}
|
||||||
@@ -152,15 +162,58 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<JsonEditor
|
{/* 自定义配置编辑器 */}
|
||||||
value={value}
|
<div className="space-y-1">
|
||||||
onChange={onChange}
|
{useCommonConfig && showPreview && (
|
||||||
placeholder=""
|
<Label className="text-xs text-muted-foreground">
|
||||||
darkMode={isDarkMode}
|
{t("codexConfig.customConfig", {
|
||||||
rows={8}
|
defaultValue: "自定义配置(覆盖通用配置)",
|
||||||
showValidation={false}
|
})}
|
||||||
language="javascript"
|
</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 && (
|
{configError && (
|
||||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
<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 { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
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 JsonEditor from "@/components/JsonEditor";
|
||||||
|
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||||
|
|
||||||
interface CommonConfigEditorProps {
|
interface CommonConfigEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -19,6 +20,8 @@ interface CommonConfigEditorProps {
|
|||||||
onModalClose: () => void;
|
onModalClose: () => void;
|
||||||
onExtract?: () => void;
|
onExtract?: () => void;
|
||||||
isExtracting?: boolean;
|
isExtracting?: boolean;
|
||||||
|
/** 最终合并后的配置(只读预览) */
|
||||||
|
finalConfig?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommonConfigEditor({
|
export function CommonConfigEditor({
|
||||||
@@ -34,24 +37,18 @@ export function CommonConfigEditor({
|
|||||||
onModalClose,
|
onModalClose,
|
||||||
onExtract,
|
onExtract,
|
||||||
isExtracting,
|
isExtracting,
|
||||||
|
finalConfig,
|
||||||
}: CommonConfigEditorProps) {
|
}: CommonConfigEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const isDarkMode = useDarkMode();
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
|
||||||
|
// 当启用通用配置时,自动显示预览
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
if (useCommonConfig && finalConfig) {
|
||||||
|
setShowPreview(true);
|
||||||
const observer = new MutationObserver(() => {
|
}
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
}, [useCommonConfig, finalConfig]);
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -75,7 +72,32 @@ export function CommonConfigEditor({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEditClick}
|
onClick={onEditClick}
|
||||||
@@ -91,20 +113,64 @@ export function CommonConfigEditor({
|
|||||||
{commonConfigError}
|
{commonConfigError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<JsonEditor
|
|
||||||
value={value}
|
{/* 自定义配置编辑器 */}
|
||||||
onChange={onChange}
|
<div className="space-y-1">
|
||||||
placeholder={`{
|
{useCommonConfig && showPreview && (
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
{t("claudeConfig.customConfig", {
|
||||||
|
defaultValue: "自定义配置(覆盖通用配置)",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
<JsonEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={`{
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||||
}
|
}
|
||||||
}`}
|
}`}
|
||||||
darkMode={isDarkMode}
|
darkMode={isDarkMode}
|
||||||
rows={14}
|
rows={useCommonConfig && showPreview ? 3 : 14}
|
||||||
showValidation={true}
|
autoHeight={useCommonConfig && showPreview}
|
||||||
language="json"
|
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>
|
</div>
|
||||||
|
|
||||||
<FullScreenPanel
|
<FullScreenPanel
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface GeminiCommonConfigModalProps {
|
|||||||
/**
|
/**
|
||||||
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
||||||
* Allows editing of common env snippet shared across Gemini providers
|
* Allows editing of common env snippet shared across Gemini providers
|
||||||
|
* Uses ENV format (KEY=VALUE) instead of JSON
|
||||||
*/
|
*/
|
||||||
export const GeminiCommonConfigModal: React.FC<
|
export const GeminiCommonConfigModal: React.FC<
|
||||||
GeminiCommonConfigModalProps
|
GeminiCommonConfigModalProps
|
||||||
@@ -88,13 +89,14 @@ export const GeminiCommonConfigModal: React.FC<
|
|||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder={`{
|
placeholder={`# Gemini 通用配置
|
||||||
"GEMINI_MODEL": "gemini-3-pro-preview"
|
# 格式: KEY=VALUE
|
||||||
}`}
|
|
||||||
|
GEMINI_MODEL=gemini-2.5-pro`}
|
||||||
darkMode={isDarkMode}
|
darkMode={isDarkMode}
|
||||||
rows={16}
|
rows={16}
|
||||||
showValidation={true}
|
showValidation={false}
|
||||||
language="json"
|
language="javascript"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface GeminiConfigEditorProps {
|
|||||||
configError: string;
|
configError: string;
|
||||||
onExtract?: () => void;
|
onExtract?: () => void;
|
||||||
isExtracting?: boolean;
|
isExtracting?: boolean;
|
||||||
|
/** 最终合并后的 env 配置(只读预览) */
|
||||||
|
finalEnv?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||||
@@ -34,6 +36,7 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
configError,
|
configError,
|
||||||
onExtract,
|
onExtract,
|
||||||
isExtracting,
|
isExtracting,
|
||||||
|
finalEnv,
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onCommonConfigToggle={onCommonConfigToggle}
|
onCommonConfigToggle={onCommonConfigToggle}
|
||||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||||
commonConfigError={commonConfigError}
|
commonConfigError={commonConfigError}
|
||||||
|
finalEnv={finalEnv}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Config JSON Section */}
|
{/* Config JSON Section */}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
import JsonEditor from "@/components/JsonEditor";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||||
|
|
||||||
interface GeminiEnvSectionProps {
|
interface GeminiEnvSectionProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -11,6 +14,8 @@ interface GeminiEnvSectionProps {
|
|||||||
onCommonConfigToggle: (checked: boolean) => void;
|
onCommonConfigToggle: (checked: boolean) => void;
|
||||||
onEditCommonConfig: () => void;
|
onEditCommonConfig: () => void;
|
||||||
commonConfigError?: string;
|
commonConfigError?: string;
|
||||||
|
/** 最终合并后的 env 配置(只读预览) */
|
||||||
|
finalEnv?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,24 +30,18 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
onCommonConfigToggle,
|
onCommonConfigToggle,
|
||||||
onEditCommonConfig,
|
onEditCommonConfig,
|
||||||
commonConfigError,
|
commonConfigError,
|
||||||
|
finalEnv,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const isDarkMode = useDarkMode();
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
|
||||||
|
// 当启用通用配置时,自动显示预览
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
if (useCommonConfig && finalEnv) {
|
||||||
|
setShowPreview(true);
|
||||||
const observer = new MutationObserver(() => {
|
}
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
}, [useCommonConfig, finalEnv]);
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = (newValue: string) => {
|
const handleChange = (newValue: string) => {
|
||||||
onChange(newValue);
|
onChange(newValue);
|
||||||
@@ -74,7 +73,32 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
</label>
|
</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 && 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEditCommonConfig}
|
onClick={onEditCommonConfig}
|
||||||
@@ -92,17 +116,60 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<JsonEditor
|
{/* 自定义配置编辑器 */}
|
||||||
value={value}
|
<div className="space-y-1">
|
||||||
onChange={handleChange}
|
{useCommonConfig && showPreview && (
|
||||||
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
<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_API_KEY=sk-your-api-key-here
|
||||||
GEMINI_MODEL=gemini-3-pro-preview`}
|
GEMINI_MODEL=gemini-3-pro-preview`}
|
||||||
darkMode={isDarkMode}
|
darkMode={isDarkMode}
|
||||||
rows={6}
|
rows={useCommonConfig && showPreview ? 3 : 6}
|
||||||
showValidation={false}
|
autoHeight={useCommonConfig && showPreview}
|
||||||
language="javascript"
|
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 && (
|
{error && (
|
||||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||||
@@ -134,22 +201,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
|||||||
configError,
|
configError,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const isDarkMode = useDarkMode();
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<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 { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
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 {
|
import type {
|
||||||
ProviderCategory,
|
ProviderCategory,
|
||||||
ProviderMeta,
|
ProviderMeta,
|
||||||
ProviderTestConfig,
|
ProviderTestConfig,
|
||||||
ProviderProxyConfig,
|
ProviderProxyConfig,
|
||||||
|
CommonConfigEnabledByApp,
|
||||||
ClaudeApiFormat,
|
ClaudeApiFormat,
|
||||||
OpenCodeModel,
|
OpenCodeModel,
|
||||||
OpenCodeProviderConfig,
|
OpenCodeProviderConfig,
|
||||||
@@ -38,6 +39,12 @@ import { OpenCodeFormFields } from "./OpenCodeFormFields";
|
|||||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
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 { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||||
import CodexConfigEditor from "./CodexConfigEditor";
|
import CodexConfigEditor from "./CodexConfigEditor";
|
||||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||||
@@ -52,7 +59,6 @@ import { GeminiFormFields } from "./GeminiFormFields";
|
|||||||
import { OmoFormFields } from "./OmoFormFields";
|
import { OmoFormFields } from "./OmoFormFields";
|
||||||
import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields";
|
import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields";
|
||||||
import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor";
|
import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor";
|
||||||
import * as configApi from "@/lib/api/config";
|
|
||||||
import type { OmoGlobalConfig } from "@/types/omo";
|
import type { OmoGlobalConfig } from "@/types/omo";
|
||||||
import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo";
|
import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo";
|
||||||
import {
|
import {
|
||||||
@@ -67,16 +73,49 @@ import {
|
|||||||
useCodexConfigState,
|
useCodexConfigState,
|
||||||
useApiKeyLink,
|
useApiKeyLink,
|
||||||
useTemplateValues,
|
useTemplateValues,
|
||||||
useCommonConfigSnippet,
|
|
||||||
useCodexCommonConfig,
|
useCodexCommonConfig,
|
||||||
useSpeedTestEndpoints,
|
useSpeedTestEndpoints,
|
||||||
useCodexTomlValidation,
|
useCodexTomlValidation,
|
||||||
useGeminiConfigState,
|
useGeminiConfigState,
|
||||||
useGeminiCommonConfig,
|
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
|
import { useCommonConfigBase } from "@/hooks/useCommonConfigBase";
|
||||||
|
import {
|
||||||
|
claudeAdapter,
|
||||||
|
createGeminiAdapter,
|
||||||
|
} from "@/hooks/commonConfigAdapters";
|
||||||
import { useProvidersQuery } from "@/lib/query/queries";
|
import { useProvidersQuery } from "@/lib/query/queries";
|
||||||
import { useOmoGlobalConfig } from "@/lib/query/omo";
|
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 CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||||
const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
||||||
@@ -407,8 +446,12 @@ export function ProviderForm({
|
|||||||
resetCodexConfig,
|
resetCodexConfig,
|
||||||
} = useCodexConfigState({ initialData });
|
} = useCodexConfigState({ initialData });
|
||||||
|
|
||||||
const { configError: codexConfigError, debouncedValidate } =
|
// 使用 Codex TOML 校验 hook (仅 Codex 模式)
|
||||||
useCodexTomlValidation();
|
const {
|
||||||
|
configError: codexConfigError,
|
||||||
|
debouncedValidate,
|
||||||
|
validateToml,
|
||||||
|
} = useCodexTomlValidation();
|
||||||
|
|
||||||
const handleCodexConfigChange = useCallback(
|
const handleCodexConfigChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -484,6 +527,18 @@ export function ProviderForm({
|
|||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
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 {
|
const {
|
||||||
useCommonConfig,
|
useCommonConfig,
|
||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
@@ -492,13 +547,11 @@ export function ProviderForm({
|
|||||||
handleCommonConfigSnippetChange,
|
handleCommonConfigSnippetChange,
|
||||||
isExtracting: isClaudeExtracting,
|
isExtracting: isClaudeExtracting,
|
||||||
handleExtract: handleClaudeExtract,
|
handleExtract: handleClaudeExtract,
|
||||||
} = useCommonConfigSnippet({
|
isLoading: isClaudeCommonConfigLoading,
|
||||||
settingsConfig: form.getValues("settingsConfig"),
|
finalValue: claudeFinalConfig,
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
getPendingCommonConfigSnippet: getPendingClaudeCommonConfigSnippet,
|
||||||
initialData: appId === "claude" ? initialData : undefined,
|
markCommonConfigSaved: markClaudeCommonConfigSaved,
|
||||||
selectedPresetId: selectedPresetId ?? undefined,
|
} = claudeCommonConfig;
|
||||||
enabled: appId === "claude",
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
useCommonConfig: useCodexCommonConfigFlag,
|
useCommonConfig: useCodexCommonConfigFlag,
|
||||||
@@ -508,11 +561,16 @@ export function ProviderForm({
|
|||||||
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
|
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
|
||||||
isExtracting: isCodexExtracting,
|
isExtracting: isCodexExtracting,
|
||||||
handleExtract: handleCodexExtract,
|
handleExtract: handleCodexExtract,
|
||||||
|
isLoading: isCodexCommonConfigLoading,
|
||||||
|
finalConfig: codexFinalConfig,
|
||||||
|
getPendingCommonConfigSnippet: getPendingCodexCommonConfigSnippet,
|
||||||
|
markCommonConfigSaved: markCodexCommonConfigSaved,
|
||||||
} = useCodexCommonConfig({
|
} = useCodexCommonConfig({
|
||||||
codexConfig,
|
codexConfig,
|
||||||
onConfigChange: handleCodexConfigChange,
|
onConfigChange: handleCodexConfigChange,
|
||||||
initialData: appId === "codex" ? initialData : undefined,
|
initialData: appId === "codex" ? initialData : undefined,
|
||||||
selectedPresetId: selectedPresetId ?? undefined,
|
selectedPresetId: selectedPresetId ?? undefined,
|
||||||
|
currentProviderId: providerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -535,52 +593,76 @@ export function ProviderForm({
|
|||||||
initialData: appId === "gemini" ? initialData : undefined,
|
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(
|
const handleGeminiApiKeyChange = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
originalHandleGeminiApiKeyChange(key);
|
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(
|
const handleGeminiBaseUrlChange = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
originalHandleGeminiBaseUrlChange(url);
|
originalHandleGeminiBaseUrlChange(url);
|
||||||
updateGeminiEnvField(
|
// 同步更新 settingsConfig
|
||||||
"GOOGLE_GEMINI_BASE_URL",
|
let config: { env?: Record<string, unknown> };
|
||||||
url.trim().replace(/\/+$/, ""),
|
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(
|
const handleGeminiModelChange = useCallback(
|
||||||
(model: string) => {
|
(model: string) => {
|
||||||
originalHandleGeminiModelChange(model);
|
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 {
|
const {
|
||||||
useCommonConfig: useGeminiCommonConfigFlag,
|
useCommonConfig: useGeminiCommonConfigFlag,
|
||||||
commonConfigSnippet: geminiCommonConfigSnippet,
|
commonConfigSnippet: geminiCommonConfigSnippet,
|
||||||
@@ -589,14 +671,11 @@ export function ProviderForm({
|
|||||||
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
|
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
|
||||||
isExtracting: isGeminiExtracting,
|
isExtracting: isGeminiExtracting,
|
||||||
handleExtract: handleGeminiExtract,
|
handleExtract: handleGeminiExtract,
|
||||||
} = useGeminiCommonConfig({
|
isLoading: isGeminiCommonConfigLoading,
|
||||||
envValue: geminiEnv,
|
finalValue: geminiFinalEnv,
|
||||||
onEnvChange: handleGeminiEnvChange,
|
getPendingCommonConfigSnippet: getPendingGeminiCommonConfigSnippet,
|
||||||
envStringToObj,
|
markCommonConfigSaved: markGeminiCommonConfigSaved,
|
||||||
envObjToString,
|
} = geminiCommonConfig;
|
||||||
initialData: appId === "gemini" ? initialData : undefined,
|
|
||||||
selectedPresetId: selectedPresetId ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
|
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
|
||||||
const existingOpencodeKeys = useMemo(() => {
|
const existingOpencodeKeys = useMemo(() => {
|
||||||
@@ -904,6 +983,13 @@ export function ProviderForm({
|
|||||||
|
|
||||||
const [isOmoConfigModalOpen, setIsOmoConfigModalOpen] = useState(false);
|
const [isOmoConfigModalOpen, setIsOmoConfigModalOpen] = useState(false);
|
||||||
const [useOmoCommonConfig, setUseOmoCommonConfig] = useState(() => {
|
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;
|
const raw = initialOmoSettings?.useCommonConfig;
|
||||||
return typeof raw === "boolean" ? raw : true;
|
return typeof raw === "boolean" ? raw : true;
|
||||||
});
|
});
|
||||||
@@ -987,6 +1073,21 @@ export function ProviderForm({
|
|||||||
setUseOmoCommonConfig(useCommonConfig);
|
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(
|
const updateOpencodeSettings = useCallback(
|
||||||
(updater: (config: Record<string, any>) => void) => {
|
(updater: (config: Record<string, any>) => void) => {
|
||||||
try {
|
try {
|
||||||
@@ -1071,7 +1172,63 @@ export function ProviderForm({
|
|||||||
|
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
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) {
|
if (appId === "claude" && templateValueEntries.length > 0) {
|
||||||
const validation = validateTemplateValues();
|
const validation = validateTemplateValues();
|
||||||
if (!validation.isValid && validation.missingField) {
|
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;
|
let settingsConfig: string;
|
||||||
|
|
||||||
if (appId === "codex") {
|
if (appId === "codex") {
|
||||||
try {
|
try {
|
||||||
const authJson = JSON.parse(codexAuth);
|
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 = {
|
const configObj = {
|
||||||
auth: authJson,
|
auth: authJson,
|
||||||
config: codexConfig ?? "",
|
config: configToSave,
|
||||||
};
|
};
|
||||||
settingsConfig = JSON.stringify(configObj);
|
settingsConfig = JSON.stringify(configObj);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1184,8 +1383,29 @@ export function ProviderForm({
|
|||||||
}
|
}
|
||||||
} else if (appId === "gemini") {
|
} else if (appId === "gemini") {
|
||||||
try {
|
try {
|
||||||
const envObj = envStringToObj(geminiEnv);
|
let envObj = envStringToObj(geminiEnv);
|
||||||
const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {};
|
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 = {
|
const combined = {
|
||||||
env: envObj,
|
env: envObj,
|
||||||
config: configObj,
|
config: configObj,
|
||||||
@@ -1196,7 +1416,6 @@ export function ProviderForm({
|
|||||||
}
|
}
|
||||||
} else if (appId === "opencode" && category === "omo") {
|
} else if (appId === "opencode" && category === "omo") {
|
||||||
const omoConfig: Record<string, unknown> = {};
|
const omoConfig: Record<string, unknown> = {};
|
||||||
omoConfig.useCommonConfig = useOmoCommonConfig;
|
|
||||||
if (Object.keys(omoAgents).length > 0) {
|
if (Object.keys(omoAgents).length > 0) {
|
||||||
omoConfig.agents = omoAgents;
|
omoConfig.agents = omoAgents;
|
||||||
}
|
}
|
||||||
@@ -1229,7 +1448,28 @@ export function ProviderForm({
|
|||||||
}
|
}
|
||||||
settingsConfig = JSON.stringify(omoConfig);
|
settingsConfig = JSON.stringify(omoConfig);
|
||||||
} else {
|
} 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 = {
|
const payload: ProviderFormValues = {
|
||||||
@@ -1307,6 +1547,16 @@ export function ProviderForm({
|
|||||||
|
|
||||||
const baseMeta: ProviderMeta | undefined =
|
const baseMeta: ProviderMeta | undefined =
|
||||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : 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 = {
|
payload.meta = {
|
||||||
...(baseMeta ?? {}),
|
...(baseMeta ?? {}),
|
||||||
endpointAutoSelect,
|
endpointAutoSelect,
|
||||||
@@ -1319,6 +1569,10 @@ export function ProviderForm({
|
|||||||
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
|
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
|
||||||
? pricingConfig.pricingModelSource
|
? pricingConfig.pricingModelSource
|
||||||
: undefined,
|
: undefined,
|
||||||
|
...(shouldPersistCommonConfigEnabledByApp
|
||||||
|
? { commonConfigEnabledByApp }
|
||||||
|
: {}),
|
||||||
|
// Claude API 格式(仅非官方 Claude 供应商使用)
|
||||||
apiFormat:
|
apiFormat:
|
||||||
appId === "claude" && category !== "official"
|
appId === "claude" && category !== "official"
|
||||||
? localApiFormat
|
? localApiFormat
|
||||||
@@ -1768,6 +2022,7 @@ export function ProviderForm({
|
|||||||
configError={codexConfigError}
|
configError={codexConfigError}
|
||||||
onExtract={handleCodexExtract}
|
onExtract={handleCodexExtract}
|
||||||
isExtracting={isCodexExtracting}
|
isExtracting={isCodexExtracting}
|
||||||
|
finalConfig={codexFinalConfig}
|
||||||
/>
|
/>
|
||||||
{settingsConfigErrorField}
|
{settingsConfigErrorField}
|
||||||
</>
|
</>
|
||||||
@@ -1789,6 +2044,7 @@ export function ProviderForm({
|
|||||||
configError={geminiConfigError}
|
configError={geminiConfigError}
|
||||||
onExtract={handleGeminiExtract}
|
onExtract={handleGeminiExtract}
|
||||||
isExtracting={isGeminiExtracting}
|
isExtracting={isGeminiExtracting}
|
||||||
|
finalEnv={envObjToString(geminiFinalEnv)}
|
||||||
/>
|
/>
|
||||||
{settingsConfigErrorField}
|
{settingsConfigErrorField}
|
||||||
</>
|
</>
|
||||||
@@ -1843,6 +2099,7 @@ export function ProviderForm({
|
|||||||
onModalClose={() => setIsCommonConfigModalOpen(false)}
|
onModalClose={() => setIsCommonConfigModalOpen(false)}
|
||||||
onExtract={handleClaudeExtract}
|
onExtract={handleClaudeExtract}
|
||||||
isExtracting={isClaudeExtracting}
|
isExtracting={isClaudeExtracting}
|
||||||
|
finalConfig={claudeFinalConfig}
|
||||||
/>
|
/>
|
||||||
{settingsConfigErrorField}
|
{settingsConfigErrorField}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export { useCodexConfigState } from "./useCodexConfigState";
|
|||||||
export { useApiKeyLink } from "./useApiKeyLink";
|
export { useApiKeyLink } from "./useApiKeyLink";
|
||||||
export { useCustomEndpoints } from "./useCustomEndpoints";
|
export { useCustomEndpoints } from "./useCustomEndpoints";
|
||||||
export { useTemplateValues } from "./useTemplateValues";
|
export { useTemplateValues } from "./useTemplateValues";
|
||||||
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
|
||||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
|
||||||
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
||||||
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||||
export { useGeminiConfigState } from "./useGeminiConfigState";
|
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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import { toast } from "sonner";
|
||||||
updateTomlCommonConfigSnippet,
|
|
||||||
hasTomlCommonConfigSnippet,
|
|
||||||
} from "@/utils/providerConfigUtils";
|
|
||||||
import { configApi } from "@/lib/api";
|
import { configApi } from "@/lib/api";
|
||||||
|
import {
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
useCommonConfigBase,
|
||||||
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
type UseCommonConfigBaseReturn,
|
||||||
# Add your common TOML configuration here`;
|
} from "@/hooks/useCommonConfigBase";
|
||||||
|
import {
|
||||||
|
codexAdapter,
|
||||||
|
preserveCodexConfigFormat,
|
||||||
|
} from "@/hooks/commonConfigAdapters";
|
||||||
|
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
||||||
|
import type { ProviderMeta } from "@/types";
|
||||||
|
|
||||||
interface UseCodexCommonConfigProps {
|
interface UseCodexCommonConfigProps {
|
||||||
|
/**
|
||||||
|
* 当前 Codex 配置(可能是纯 TOML 或 JSON wrapper 格式)
|
||||||
|
*/
|
||||||
codexConfig: string;
|
codexConfig: string;
|
||||||
|
/**
|
||||||
|
* 配置变化回调
|
||||||
|
*/
|
||||||
onConfigChange: (config: string) => void;
|
onConfigChange: (config: string) => void;
|
||||||
|
/**
|
||||||
|
* 初始数据(编辑模式)
|
||||||
|
*/
|
||||||
initialData?: {
|
initialData?: {
|
||||||
settingsConfig?: Record<string, unknown>;
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
meta?: ProviderMeta;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 当前选中的预设 ID
|
||||||
|
*/
|
||||||
selectedPresetId?: string;
|
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 格式)
|
* 管理 Codex 通用配置片段
|
||||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
*
|
||||||
|
* 基于 useCommonConfigBase 泛型 Hook + Codex TOML 适配器实现。
|
||||||
|
* 额外处理 Codex 双格式(纯 TOML / JSON wrapper)的写回逻辑。
|
||||||
*/
|
*/
|
||||||
export function useCodexCommonConfig({
|
export function useCodexCommonConfig({
|
||||||
codexConfig,
|
codexConfig,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
initialData,
|
initialData,
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
}: UseCodexCommonConfigProps) {
|
}: UseCodexCommonConfigProps): UseCodexCommonConfigReturn {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
|
||||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
|
||||||
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
|
|
||||||
);
|
|
||||||
const [commonConfigError, setCommonConfigError] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isExtracting, setIsExtracting] = useState(false);
|
|
||||||
|
|
||||||
// 用于跟踪是否正在通过通用配置更新
|
// 额外的 isExtracting 状态(base hook 的 handleExtract 不适用于 Codex)
|
||||||
const isUpdatingFromCommonConfig = useRef(false);
|
const [localIsExtracting, setLocalIsExtracting] = useState(false);
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
const [localExtractError, setLocalExtractError] = useState("");
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
const base: UseCommonConfigBaseReturn<string> = useCommonConfigBase({
|
||||||
useEffect(() => {
|
adapter: codexAdapter,
|
||||||
hasInitializedNewMode.current = false;
|
inputValue: codexConfig,
|
||||||
}, [selectedPresetId]);
|
onInputChange: onConfigChange,
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("codex");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const legacySnippet =
|
|
||||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacySnippet && legacySnippet.trim()) {
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("codex", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载 Codex 通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData?.settingsConfig && !isLoading) {
|
|
||||||
const config =
|
|
||||||
typeof initialData.settingsConfig.config === "string"
|
|
||||||
? initialData.settingsConfig.config
|
|
||||||
: "";
|
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}
|
|
||||||
}, [initialData, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
|
||||||
useEffect(() => {
|
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
|
||||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
|
||||||
hasInitializedNewMode.current = true;
|
|
||||||
|
|
||||||
// 检查 TOML 片段是否有实质内容(不只是注释和空行)
|
|
||||||
const lines = commonConfigSnippet.split("\n");
|
|
||||||
const hasContent = lines.some((line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
return trimmed && !trimmed.startsWith("#");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasContent) {
|
|
||||||
setUseCommonConfig(true);
|
|
||||||
// 合并通用配置到当前配置
|
|
||||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!error) {
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
initialData,
|
initialData,
|
||||||
commonConfigSnippet,
|
selectedPresetId,
|
||||||
isLoading,
|
});
|
||||||
codexConfig,
|
|
||||||
onConfigChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
// Codex 自定义 handleExtract:处理双格式写回 + 状态管理
|
||||||
const handleCommonConfigToggle = useCallback(
|
|
||||||
(checked: boolean) => {
|
|
||||||
const { updatedConfig, error: snippetError } =
|
|
||||||
updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
checked,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snippetError) {
|
|
||||||
setCommonConfigError(snippetError);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
setUseCommonConfig(checked);
|
|
||||||
// 标记正在通过通用配置更新
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[codexConfig, commonConfigSnippet, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
|
|
||||||
console.error("保存 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json
|
|
||||||
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
|
|
||||||
console.error("保存 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 若当前启用通用配置,需要替换为最新片段
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const removeResult = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (removeResult.error) {
|
|
||||||
setCommonConfigError(removeResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const addResult = updateTomlCommonConfigSnippet(
|
|
||||||
removeResult.updatedConfig,
|
|
||||||
value,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (addResult.error) {
|
|
||||||
setCommonConfigError(addResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记正在通过通用配置更新,避免触发状态检查
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(addResult.updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}, [codexConfig, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
const handleExtract = useCallback(async () => {
|
||||||
setIsExtracting(true);
|
setLocalIsExtracting(true);
|
||||||
setCommonConfigError("");
|
setLocalExtractError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("codex", {
|
const request = codexAdapter.buildExtractRequest(base.finalValue);
|
||||||
settingsConfig: JSON.stringify({
|
const extracted = await configApi.extractCommonConfigSnippet(
|
||||||
config: codexConfig ?? "",
|
"codex",
|
||||||
}),
|
request,
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!extracted || !extracted.trim()) {
|
if (!extracted || !extracted.trim()) {
|
||||||
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
|
setLocalExtractError(t("codexConfig.extractNoCommonConfig"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新片段状态
|
// 验证 TOML 格式
|
||||||
setCommonConfigSnippetState(extracted);
|
const parseResult = codexAdapter.parseSnippet(extracted);
|
||||||
|
if (parseResult.error || parseResult.config === null) {
|
||||||
|
setLocalExtractError(
|
||||||
|
t("codexConfig.extractedTomlInvalid", {
|
||||||
|
defaultValue: "提取的配置 TOML 格式错误",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 保存到后端
|
// 从 config 中移除与 extracted 相同的部分
|
||||||
await configApi.setCommonConfigSnippet("codex", 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) {
|
} catch (error) {
|
||||||
console.error("提取 Codex 通用配置失败:", error);
|
console.error("提取 Codex 通用配置失败:", error);
|
||||||
setCommonConfigError(
|
setLocalExtractError(
|
||||||
t("codexConfig.extractFailed", { error: String(error) }),
|
t("codexConfig.extractFailed", {
|
||||||
|
error: String(error),
|
||||||
|
defaultValue: "提取失败",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsExtracting(false);
|
setLocalIsExtracting(false);
|
||||||
}
|
}
|
||||||
}, [codexConfig, t]);
|
}, [base, codexConfig, onConfigChange, t]);
|
||||||
|
|
||||||
|
// 合并 error:优先显示 extract 错误,其次是 base 的错误
|
||||||
|
const combinedError = localExtractError || base.commonConfigError;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useCommonConfig,
|
useCommonConfig: base.useCommonConfig,
|
||||||
commonConfigSnippet,
|
commonConfigSnippet: base.commonConfigSnippet,
|
||||||
commonConfigError,
|
commonConfigError: combinedError,
|
||||||
isLoading,
|
isLoading: base.isLoading,
|
||||||
isExtracting,
|
isExtracting: localIsExtracting,
|
||||||
handleCommonConfigToggle,
|
handleCommonConfigToggle: base.handleCommonConfigToggle,
|
||||||
handleCommonConfigSnippetChange,
|
handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange,
|
||||||
handleExtract,
|
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 { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
extractCodexBaseUrl,
|
extractCodexBaseUrl,
|
||||||
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||||
@@ -18,6 +19,7 @@ interface UseCodexConfigStateProps {
|
|||||||
* Codex 配置包含两部分:auth.json (JSON) 和 config.toml (TOML 字符串)
|
* Codex 配置包含两部分:auth.json (JSON) 和 config.toml (TOML 字符串)
|
||||||
*/
|
*/
|
||||||
export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [codexAuth, setCodexAuthState] = useState("");
|
const [codexAuth, setCodexAuthState] = useState("");
|
||||||
const [codexConfig, setCodexConfigState] = useState("");
|
const [codexConfig, setCodexConfigState] = useState("");
|
||||||
const [codexApiKey, setCodexApiKey] = useState("");
|
const [codexApiKey, setCodexApiKey] = useState("");
|
||||||
@@ -109,18 +111,21 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
}, [codexAuth, codexApiKey]);
|
}, [codexAuth, codexApiKey]);
|
||||||
|
|
||||||
// 验证 Codex Auth JSON
|
// 验证 Codex Auth JSON
|
||||||
const validateCodexAuth = useCallback((value: string): string => {
|
const validateCodexAuth = useCallback(
|
||||||
if (!value.trim()) return "";
|
(value: string): string => {
|
||||||
try {
|
if (!value.trim()) return "";
|
||||||
const parsed = JSON.parse(value);
|
try {
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
const parsed = JSON.parse(value);
|
||||||
return "Auth JSON must be an object";
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return t("providerForm.authJsonRequired");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
} catch {
|
||||||
|
return t("providerForm.authJsonError");
|
||||||
}
|
}
|
||||||
return "";
|
},
|
||||||
} catch {
|
[t],
|
||||||
return "Invalid JSON format";
|
);
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 设置 auth 并验证
|
// 设置 auth 并验证
|
||||||
const setCodexAuth = useCallback(
|
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 { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface UseGeminiConfigStateProps {
|
interface UseGeminiConfigStateProps {
|
||||||
initialData?: {
|
initialData?: {
|
||||||
@@ -13,6 +14,7 @@ interface UseGeminiConfigStateProps {
|
|||||||
export function useGeminiConfigState({
|
export function useGeminiConfigState({
|
||||||
initialData,
|
initialData,
|
||||||
}: UseGeminiConfigStateProps) {
|
}: UseGeminiConfigStateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [geminiEnv, setGeminiEnvState] = useState("");
|
const [geminiEnv, setGeminiEnvState] = useState("");
|
||||||
const [geminiConfig, setGeminiConfigState] = useState("");
|
const [geminiConfig, setGeminiConfigState] = useState("");
|
||||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||||
@@ -119,18 +121,21 @@ export function useGeminiConfigState({
|
|||||||
}, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);
|
}, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);
|
||||||
|
|
||||||
// 验证 Gemini Config JSON
|
// 验证 Gemini Config JSON
|
||||||
const validateGeminiConfig = useCallback((value: string): string => {
|
const validateGeminiConfig = useCallback(
|
||||||
if (!value.trim()) return ""; // 空值允许
|
(value: string): string => {
|
||||||
try {
|
if (!value.trim()) return ""; // 空值允许
|
||||||
const parsed = JSON.parse(value);
|
try {
|
||||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
const parsed = JSON.parse(value);
|
||||||
return "";
|
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 {
|
[t],
|
||||||
return "Invalid JSON format";
|
);
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 设置 env
|
// 设置 env
|
||||||
const setGeminiEnv = useCallback((value: string) => {
|
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",
|
"search": "Search",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"deleting": "Deleting..."
|
"deleting": "Deleting...",
|
||||||
|
"readonly": "Read-only"
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "Enter API Key",
|
"placeholder": "Enter API Key",
|
||||||
@@ -54,11 +55,18 @@
|
|||||||
"editCommonConfig": "Edit Common Config",
|
"editCommonConfig": "Edit Common Config",
|
||||||
"editCommonConfigTitle": "Edit Common Config Snippet",
|
"editCommonConfigTitle": "Edit Common Config Snippet",
|
||||||
"commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked",
|
"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",
|
"fullSettingsHint": "Full Claude Code settings.json content",
|
||||||
"extractFromCurrent": "Extract from Editor",
|
"extractFromCurrent": "Extract from Editor",
|
||||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
"extractNoCommonConfig": "No common config available to extract from editor",
|
||||||
"extractFailed": "Extract failed: {{error}}",
|
"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": {
|
"header": {
|
||||||
"viewOnGithub": "View on GitHub",
|
"viewOnGithub": "View on GitHub",
|
||||||
@@ -538,7 +546,10 @@
|
|||||||
"categoryOfficial": "Official",
|
"categoryOfficial": "Official",
|
||||||
"categoryCnOfficial": "Opensource Official",
|
"categoryCnOfficial": "Opensource Official",
|
||||||
"categoryAggregation": "Aggregation",
|
"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": {
|
"endpointTest": {
|
||||||
"title": "API Endpoint Management",
|
"title": "API Endpoint Management",
|
||||||
@@ -603,10 +614,12 @@
|
|||||||
"editCommonConfig": "Edit Common Config",
|
"editCommonConfig": "Edit Common Config",
|
||||||
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
|
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
|
||||||
"commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked",
|
"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",
|
"apiUrlLabel": "API Request URL",
|
||||||
"extractFromCurrent": "Extract from Editor",
|
"extractFromCurrent": "Extract from Editor",
|
||||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
"extractNoCommonConfig": "No common config available to extract from editor",
|
||||||
"extractFailed": "Extract failed: {{error}}",
|
"extractFailed": "Extract failed: {{error}}",
|
||||||
|
"extractSuccessNeedSave": "Common config extracted, click Save to complete",
|
||||||
"saveFailed": "Save failed: {{error}}"
|
"saveFailed": "Save failed: {{error}}"
|
||||||
},
|
},
|
||||||
"geminiConfig": {
|
"geminiConfig": {
|
||||||
@@ -621,14 +634,17 @@
|
|||||||
"extractFromCurrent": "Extract from Editor",
|
"extractFromCurrent": "Extract from Editor",
|
||||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
"extractNoCommonConfig": "No common config available to extract from editor",
|
||||||
"extractFailed": "Extract failed: {{error}}",
|
"extractFailed": "Extract failed: {{error}}",
|
||||||
|
"extractSuccessNeedSave": "Common config extracted, click Save to complete",
|
||||||
"saveFailed": "Save failed: {{error}}",
|
"saveFailed": "Save failed: {{error}}",
|
||||||
"extractedConfigInvalid": "Extracted config format is invalid",
|
"extractedConfigInvalid": "Extracted config format is invalid",
|
||||||
"invalidJsonFormat": "Common config snippet format error (must be valid JSON)",
|
"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}})",
|
"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",
|
"commonConfigInvalidValues": "Common config snippet values must be strings",
|
||||||
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
|
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
|
||||||
"configMergeFailed": "Config merge failed: {{error}}",
|
"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": {
|
"opencode": {
|
||||||
"npmPackage": "API Format",
|
"npmPackage": "API Format",
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
"search": "検索",
|
"search": "検索",
|
||||||
"reset": "リセット",
|
"reset": "リセット",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"deleting": "削除中..."
|
"deleting": "削除中...",
|
||||||
|
"readonly": "読み取り専用"
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "API Key を入力",
|
"placeholder": "API Key を入力",
|
||||||
@@ -54,11 +55,18 @@
|
|||||||
"editCommonConfig": "共通設定を編集",
|
"editCommonConfig": "共通設定を編集",
|
||||||
"editCommonConfigTitle": "共通設定スニペットを編集",
|
"editCommonConfigTitle": "共通設定スニペットを編集",
|
||||||
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
|
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
|
||||||
|
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
||||||
"fullSettingsHint": "Claude Code の settings.json 全文",
|
"fullSettingsHint": "Claude Code の settings.json 全文",
|
||||||
"extractFromCurrent": "編集内容から抽出",
|
"extractFromCurrent": "編集内容から抽出",
|
||||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||||
"saveFailed": "保存に失敗しました: {{error}}"
|
"extractSuccessNeedSave": "共通設定を抽出しました。保存ボタンをクリックして完了してください",
|
||||||
|
"saveFailed": "保存に失敗しました: {{error}}",
|
||||||
|
"hidePreview": "マージプレビューを非表示",
|
||||||
|
"showPreview": "マージプレビューを表示",
|
||||||
|
"customConfig": "カスタム設定(共通設定を上書き)",
|
||||||
|
"mergedPreview": "マージプレビュー(読み取り専用)",
|
||||||
|
"mergedPreviewHint": "共通設定 + カスタム設定 = 最終設定"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"viewOnGithub": "GitHub で見る",
|
"viewOnGithub": "GitHub で見る",
|
||||||
@@ -538,7 +546,10 @@
|
|||||||
"categoryOfficial": "公式",
|
"categoryOfficial": "公式",
|
||||||
"categoryCnOfficial": "オープンソース公式",
|
"categoryCnOfficial": "オープンソース公式",
|
||||||
"categoryAggregation": "アグリゲーター",
|
"categoryAggregation": "アグリゲーター",
|
||||||
"categoryThirdParty": "サードパーティ"
|
"categoryThirdParty": "サードパーティ",
|
||||||
|
"commonConfigLoading": "共通設定を読み込み中です。保存する前にお待ちください",
|
||||||
|
"commonConfigSyncFailed": "共通設定の同期に失敗しました。再試行してください",
|
||||||
|
"saveCommonConfigFailed": "共通設定の保存に失敗しました"
|
||||||
},
|
},
|
||||||
"endpointTest": {
|
"endpointTest": {
|
||||||
"title": "API エンドポイント管理",
|
"title": "API エンドポイント管理",
|
||||||
@@ -603,10 +614,12 @@
|
|||||||
"editCommonConfig": "共通設定を編集",
|
"editCommonConfig": "共通設定を編集",
|
||||||
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
|
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
|
||||||
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
|
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
|
||||||
|
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
||||||
"apiUrlLabel": "API リクエスト URL",
|
"apiUrlLabel": "API リクエスト URL",
|
||||||
"extractFromCurrent": "編集内容から抽出",
|
"extractFromCurrent": "編集内容から抽出",
|
||||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||||
|
"extractSuccessNeedSave": "共通設定を抽出しました。保存ボタンをクリックして完了してください",
|
||||||
"saveFailed": "保存に失敗しました: {{error}}"
|
"saveFailed": "保存に失敗しました: {{error}}"
|
||||||
},
|
},
|
||||||
"geminiConfig": {
|
"geminiConfig": {
|
||||||
@@ -621,14 +634,17 @@
|
|||||||
"extractFromCurrent": "編集内容から抽出",
|
"extractFromCurrent": "編集内容から抽出",
|
||||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||||
|
"extractSuccessNeedSave": "共通設定を抽出しました。保存ボタンをクリックして完了してください",
|
||||||
"saveFailed": "保存に失敗しました: {{error}}",
|
"saveFailed": "保存に失敗しました: {{error}}",
|
||||||
"extractedConfigInvalid": "抽出した設定のフォーマットが不正です",
|
"extractedConfigInvalid": "抽出した設定のフォーマットが不正です",
|
||||||
"invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)",
|
"invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)",
|
||||||
|
"invalidEnvFormat": "設定フォーマットエラー(KEY=VALUE 形式を使用してください、各行に1つ)",
|
||||||
"commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}})",
|
"commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}})",
|
||||||
"commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります",
|
"commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります",
|
||||||
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
||||||
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
|
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
|
||||||
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
|
"configReplaceFailed": "設定の置換に失敗しました: {{error}}",
|
||||||
|
"forbiddenKeysWarning": "以下のキーはフィルタされました(共通設定では許可されていません):{{keys}}"
|
||||||
},
|
},
|
||||||
"opencode": {
|
"opencode": {
|
||||||
"npmPackage": "API フォーマット",
|
"npmPackage": "API フォーマット",
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
"search": "查询",
|
"search": "查询",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"deleting": "删除中..."
|
"deleting": "删除中...",
|
||||||
|
"readonly": "只读"
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "请输入API Key",
|
"placeholder": "请输入API Key",
|
||||||
@@ -54,11 +55,18 @@
|
|||||||
"editCommonConfig": "编辑通用配置",
|
"editCommonConfig": "编辑通用配置",
|
||||||
"editCommonConfigTitle": "编辑通用配置片段",
|
"editCommonConfigTitle": "编辑通用配置片段",
|
||||||
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
|
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
|
||||||
|
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
||||||
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
|
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
|
||||||
"extractFromCurrent": "从编辑内容提取",
|
"extractFromCurrent": "从编辑内容提取",
|
||||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||||
"extractFailed": "提取失败: {{error}}",
|
"extractFailed": "提取失败: {{error}}",
|
||||||
"saveFailed": "保存失败: {{error}}"
|
"extractSuccessNeedSave": "已提取通用配置,点击保存按钮完成保存",
|
||||||
|
"saveFailed": "保存失败: {{error}}",
|
||||||
|
"hidePreview": "隐藏合并预览",
|
||||||
|
"showPreview": "显示合并预览",
|
||||||
|
"customConfig": "自定义配置(覆盖通用配置)",
|
||||||
|
"mergedPreview": "合并预览(只读)",
|
||||||
|
"mergedPreviewHint": "通用配置 + 自定义配置 = 最终配置"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"viewOnGithub": "在 GitHub 上查看",
|
"viewOnGithub": "在 GitHub 上查看",
|
||||||
@@ -538,7 +546,10 @@
|
|||||||
"categoryOfficial": "官方",
|
"categoryOfficial": "官方",
|
||||||
"categoryCnOfficial": "开源官方",
|
"categoryCnOfficial": "开源官方",
|
||||||
"categoryAggregation": "聚合服务",
|
"categoryAggregation": "聚合服务",
|
||||||
"categoryThirdParty": "第三方"
|
"categoryThirdParty": "第三方",
|
||||||
|
"commonConfigLoading": "通用配置正在加载中,请稍候再保存",
|
||||||
|
"commonConfigSyncFailed": "通用配置同步部分失败,请重试",
|
||||||
|
"saveCommonConfigFailed": "保存通用配置失败"
|
||||||
},
|
},
|
||||||
"endpointTest": {
|
"endpointTest": {
|
||||||
"title": "请求地址管理",
|
"title": "请求地址管理",
|
||||||
@@ -603,10 +614,12 @@
|
|||||||
"editCommonConfig": "编辑通用配置",
|
"editCommonConfig": "编辑通用配置",
|
||||||
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
|
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
|
||||||
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
|
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
|
||||||
|
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
||||||
"apiUrlLabel": "API 请求地址",
|
"apiUrlLabel": "API 请求地址",
|
||||||
"extractFromCurrent": "从编辑内容提取",
|
"extractFromCurrent": "从编辑内容提取",
|
||||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||||
"extractFailed": "提取失败: {{error}}",
|
"extractFailed": "提取失败: {{error}}",
|
||||||
|
"extractSuccessNeedSave": "已提取通用配置,点击保存按钮完成保存",
|
||||||
"saveFailed": "保存失败: {{error}}"
|
"saveFailed": "保存失败: {{error}}"
|
||||||
},
|
},
|
||||||
"geminiConfig": {
|
"geminiConfig": {
|
||||||
@@ -621,14 +634,17 @@
|
|||||||
"extractFromCurrent": "从编辑内容提取",
|
"extractFromCurrent": "从编辑内容提取",
|
||||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||||
"extractFailed": "提取失败: {{error}}",
|
"extractFailed": "提取失败: {{error}}",
|
||||||
|
"extractSuccessNeedSave": "已提取通用配置,点击保存按钮完成保存",
|
||||||
"saveFailed": "保存失败: {{error}}",
|
"saveFailed": "保存失败: {{error}}",
|
||||||
"extractedConfigInvalid": "提取的配置格式错误",
|
"extractedConfigInvalid": "提取的配置格式错误",
|
||||||
"invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)",
|
"invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)",
|
||||||
|
"invalidEnvFormat": "配置格式错误(请使用 KEY=VALUE 格式,每行一个)",
|
||||||
"commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}})",
|
"commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}})",
|
||||||
"commonConfigInvalidValues": "通用配置片段的值必须是字符串",
|
"commonConfigInvalidValues": "通用配置片段的值必须是字符串",
|
||||||
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
||||||
"configMergeFailed": "配置合并失败: {{error}}",
|
"configMergeFailed": "配置合并失败: {{error}}",
|
||||||
"configReplaceFailed": "配置替换失败: {{error}}"
|
"configReplaceFailed": "配置替换失败: {{error}}",
|
||||||
|
"forbiddenKeysWarning": "以下密钥已被自动过滤(禁止在通用配置中设置):{{keys}}"
|
||||||
},
|
},
|
||||||
"opencode": {
|
"opencode": {
|
||||||
"npmPackage": "接口格式",
|
"npmPackage": "接口格式",
|
||||||
|
|||||||
+477
-22
@@ -1,28 +1,70 @@
|
|||||||
// 配置相关 API
|
// 配置相关 API
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
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> {
|
export interface SyncResult {
|
||||||
return invoke<string | null>("get_claude_common_config_snippet");
|
/** 成功更新的供应商数量 */
|
||||||
|
count: number;
|
||||||
|
/** 错误信息(如果有) */
|
||||||
|
error?: string;
|
||||||
|
/** 是否已排队等待执行(debounce 中) */
|
||||||
|
queued?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 同步结果回调类型 */
|
||||||
* 设置 Claude 通用配置片段(已废弃,使用 setCommonConfigSnippet)
|
export type SyncResultCallback = (result: SyncResult) => void;
|
||||||
* @param snippet - 通用配置片段(JSON 字符串)
|
|
||||||
* @throws 如果 JSON 格式无效
|
// 每个 appType 的 debounce 定时器
|
||||||
* @deprecated 使用 setCommonConfigSnippet('claude', snippet) 替代
|
const syncDebounceTimers: Record<
|
||||||
*/
|
SyncAppType,
|
||||||
export async function setClaudeCommonConfigSnippet(
|
ReturnType<typeof setTimeout> | null
|
||||||
snippet: string,
|
> = {
|
||||||
): Promise<void> {
|
claude: null,
|
||||||
return invoke("set_claude_common_config_snippet", { snippet });
|
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 appType - 应用类型(claude/codex/gemini)
|
||||||
* @param snippet - 通用配置片段(原始字符串)
|
* @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(
|
export async function setCommonConfigSnippet(
|
||||||
appType: AppType,
|
appType: AppType,
|
||||||
@@ -56,14 +101,14 @@ export async function setCommonConfigSnippet(
|
|||||||
*
|
*
|
||||||
* @param appType - 应用类型(claude/codex/gemini)
|
* @param appType - 应用类型(claude/codex/gemini)
|
||||||
* @param options - 可选:提取来源
|
* @param options - 可选:提取来源
|
||||||
* @returns 提取的通用配置片段(JSON/TOML 字符串)
|
* @returns 提取的通用配置片段(Claude: JSON, Codex: TOML, Gemini: ENV)
|
||||||
*/
|
*/
|
||||||
export type ExtractCommonConfigSnippetOptions = {
|
export type ExtractCommonConfigSnippetOptions = {
|
||||||
settingsConfig?: string;
|
settingsConfig?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function extractCommonConfigSnippet(
|
export async function extractCommonConfigSnippet(
|
||||||
appType: Exclude<AppType, "omo">,
|
appType: CommonConfigAppType,
|
||||||
options?: ExtractCommonConfigSnippetOptions,
|
options?: ExtractCommonConfigSnippetOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const args: Record<string, unknown> = { appType };
|
const args: Record<string, unknown> = { appType };
|
||||||
@@ -75,3 +120,413 @@ export async function extractCommonConfigSnippet(
|
|||||||
|
|
||||||
return invoke<string>("extract_common_config_snippet", args);
|
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;
|
proxyPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CommonConfigEnabledByApp = Partial<{
|
||||||
|
claude: boolean;
|
||||||
|
codex: boolean;
|
||||||
|
gemini: boolean;
|
||||||
|
opencode: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
||||||
export interface ProviderMeta {
|
export interface ProviderMeta {
|
||||||
// 自定义端点:以 URL 为键,值为端点信息
|
// 自定义端点:以 URL 为键,值为端点信息
|
||||||
@@ -140,6 +147,10 @@ export interface ProviderMeta {
|
|||||||
costMultiplier?: string;
|
costMultiplier?: string;
|
||||||
// 供应商计费模式来源
|
// 供应商计费模式来源
|
||||||
pricingModelSource?: string;
|
pricingModelSource?: string;
|
||||||
|
// 是否启用通用配置片段(用于跨供应商保持勾选状态)
|
||||||
|
commonConfigEnabled?: boolean;
|
||||||
|
// 按应用记录通用配置启用状态(优先于 commonConfigEnabled)
|
||||||
|
commonConfigEnabledByApp?: CommonConfigEnabledByApp;
|
||||||
// Claude API 格式(仅 Claude 供应商使用)
|
// Claude API 格式(仅 Claude 供应商使用)
|
||||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
// - "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 type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||||||
import { normalizeQuotes } from "@/utils/textNormalization";
|
import { normalizeQuotes } from "@/utils/textNormalization";
|
||||||
|
import { isPlainObject } from "@/utils/configMerge";
|
||||||
|
|
||||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
// Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用)
|
||||||
return Object.prototype.toString.call(value) === "[object Object]";
|
export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
||||||
};
|
"GOOGLE_GEMINI_BASE_URL",
|
||||||
|
"GEMINI_API_KEY",
|
||||||
const deepMerge = (
|
] as const;
|
||||||
target: Record<string, any>,
|
export type GeminiForbiddenEnvKey =
|
||||||
source: Record<string, any>,
|
(typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
||||||
): Record<string, any> => {
|
|
||||||
Object.entries(source).forEach(([key, value]) => {
|
|
||||||
if (isPlainObject(value)) {
|
|
||||||
if (!isPlainObject(target[key])) {
|
|
||||||
target[key] = {};
|
|
||||||
}
|
|
||||||
deepMerge(target[key], value);
|
|
||||||
} else {
|
|
||||||
// 直接覆盖非对象字段(数组/基础类型)
|
|
||||||
target[key] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return target;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deepRemove = (
|
|
||||||
target: Record<string, any>,
|
|
||||||
source: Record<string, any>,
|
|
||||||
) => {
|
|
||||||
Object.entries(source).forEach(([key, value]) => {
|
|
||||||
if (!(key in target)) return;
|
|
||||||
|
|
||||||
if (isPlainObject(value) && isPlainObject(target[key])) {
|
|
||||||
// 只移除完全匹配的嵌套属性
|
|
||||||
deepRemove(target[key], value);
|
|
||||||
if (Object.keys(target[key]).length === 0) {
|
|
||||||
delete target[key];
|
|
||||||
}
|
|
||||||
} else if (isSubset(target[key], value)) {
|
|
||||||
// 只有当值完全匹配时才删除
|
|
||||||
delete target[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSubset = (target: any, source: any): boolean => {
|
|
||||||
if (isPlainObject(source)) {
|
|
||||||
if (!isPlainObject(target)) return false;
|
|
||||||
return Object.entries(source).every(([key, value]) =>
|
|
||||||
isSubset(target[key], value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(source)) {
|
|
||||||
if (!Array.isArray(target) || target.length !== source.length) return false;
|
|
||||||
return source.every((item, index) => isSubset(target[index], item));
|
|
||||||
}
|
|
||||||
|
|
||||||
return target === source;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 深拷贝函数
|
|
||||||
const deepClone = <T>(obj: T): T => {
|
|
||||||
if (obj === null || typeof obj !== "object") return obj;
|
|
||||||
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
|
||||||
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
|
|
||||||
if (obj instanceof Object) {
|
|
||||||
const clonedObj = {} as T;
|
|
||||||
for (const key in obj) {
|
|
||||||
if (obj.hasOwnProperty(key)) {
|
|
||||||
clonedObj[key] = deepClone(obj[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return clonedObj;
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface UpdateCommonConfigResult {
|
|
||||||
updatedConfig: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证JSON配置格式
|
// 验证JSON配置格式
|
||||||
export const validateJsonConfig = (
|
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)
|
// 读取配置中的 API Key(支持 Claude, Codex, Gemini)
|
||||||
export const getApiKeyFromConfig = (
|
export const getApiKeyFromConfig = (
|
||||||
jsonString: string,
|
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 base_url utils ==========
|
||||||
|
|
||||||
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
||||||
@@ -530,3 +317,293 @@ export const setCodexModelName = (
|
|||||||
const lines = normalizedText.split("\n");
|
const lines = normalizedText.split("\n");
|
||||||
return `${replacementLine}\n${lines.join("\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);
|
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();
|
it("clears loading flag when all mutations idle", () => {
|
||||||
const { result } = renderHook(() => useProviderActions("claude"), {
|
addProviderMutation.isPending = false;
|
||||||
wrapper,
|
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