mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
fix(config): resolve common config sync issues across frontend and backend
- Fix backfill pollution: extract custom config from live when common config enabled - Fix proxy backup: merge common config before backup to preserve full config - Unify null override semantics: null no longer overrides in both frontend and backend - Add missing i18n keys for common config UI - Hide format button in readonly JsonEditor - Add mapGeminiWarningToI18n for user-friendly warning messages
This commit is contained in:
@@ -705,7 +705,7 @@ fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> Merge
|
||||
/// - ENV format: KEY=VALUE lines
|
||||
/// - Flat JSON: {"KEY": "VALUE", ...}
|
||||
/// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
|
||||
fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map<String, JsonValue> {
|
||||
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();
|
||||
|
||||
@@ -13,6 +13,7 @@ use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::config_merge::{extract_json_difference, extract_toml_difference_str, is_common_config_enabled};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{Provider, UsageResult};
|
||||
use crate::services::mcp::McpService;
|
||||
@@ -386,7 +387,18 @@ impl ProviderService {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// 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);
|
||||
}
|
||||
@@ -459,6 +471,87 @@ impl ProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract custom config from live config (remove common config parts).
|
||||
///
|
||||
/// This is used during backfill to avoid polluting the provider's settings_config
|
||||
/// with common config values that should remain in the common config snippet.
|
||||
fn extract_custom_from_live(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
live_config: &Value,
|
||||
) -> Result<Value, AppError> {
|
||||
// Get common config snippet from database
|
||||
let common_snippet = state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())?
|
||||
.unwrap_or_default();
|
||||
|
||||
if common_snippet.trim().is_empty() {
|
||||
// No common config, return live config as-is
|
||||
return Ok(live_config.clone());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
// Parse common config as JSON
|
||||
let common_config: Value = serde_json::from_str(&common_snippet).map_err(|e| {
|
||||
AppError::Config(format!("Failed to parse common config snippet: {e}"))
|
||||
})?;
|
||||
|
||||
// Extract difference (custom = live - common)
|
||||
let (custom_config, _) = extract_json_difference(live_config, &common_config);
|
||||
Ok(custom_config)
|
||||
}
|
||||
AppType::Codex => {
|
||||
// Codex: Extract TOML config field difference
|
||||
let mut result = live_config.clone();
|
||||
|
||||
if let Some(config_str) = live_config.get("config").and_then(|v| v.as_str()) {
|
||||
// Extract TOML difference for config field
|
||||
// Returns (custom_toml, has_common_keys, error)
|
||||
let (custom_toml, _, _) = extract_toml_difference_str(config_str, &common_snippet);
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(custom_toml));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
// Gemini: Extract env field difference
|
||||
// Parse common config (supports ENV format and JSON format)
|
||||
let common_env = crate::config_merge::parse_gemini_common_snippet(&common_snippet);
|
||||
|
||||
if common_env.is_empty() {
|
||||
return Ok(live_config.clone());
|
||||
}
|
||||
|
||||
let mut result = live_config.clone();
|
||||
|
||||
if let Some(live_env) = live_config.get("env").and_then(|v| v.as_object()) {
|
||||
// Extract difference: custom = live_env - common_env
|
||||
let mut custom_env = serde_json::Map::new();
|
||||
for (key, value) in live_env {
|
||||
if common_env.get(key) != Some(value) {
|
||||
// Key doesn't exist in common or value is different
|
||||
custom_env.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("env".to_string(), Value::Object(custom_env));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support common config
|
||||
Ok(live_config.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config for Claude (JSON format)
|
||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let mut config = settings.clone();
|
||||
|
||||
@@ -1506,26 +1506,57 @@ impl ProxyService {
|
||||
///
|
||||
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,
|
||||
/// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。
|
||||
///
|
||||
/// **重要**: 新架构下 settings_config 存储的是自定义配置(custom diff),
|
||||
/// 备份时需要先与 common config 合并,生成完整的 finalConfig。
|
||||
pub async fn update_live_backup_from_provider(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
|
||||
// Get common config snippet for merge
|
||||
let common_snippet = self
|
||||
.db
|
||||
.get_config_snippet(app_type)
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// Merge custom config with common config to get final config
|
||||
let merge_result = merge_config_for_live(
|
||||
&app_type_enum,
|
||||
provider,
|
||||
common_snippet.as_deref(),
|
||||
);
|
||||
|
||||
// Log warning if any
|
||||
if let Some(warning) = &merge_result.warning {
|
||||
log::warn!(
|
||||
"Common config merge warning for {} provider '{}': {}",
|
||||
app_type,
|
||||
provider.id,
|
||||
warning
|
||||
);
|
||||
}
|
||||
|
||||
let final_config = merge_result.config;
|
||||
|
||||
let backup_json = match app_type {
|
||||
"claude" => {
|
||||
// Claude: settings_config 直接作为备份
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
// Claude: 使用合并后的 final config 作为备份
|
||||
serde_json::to_string(&final_config)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
|
||||
}
|
||||
"codex" => {
|
||||
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
// Codex: 使用合并后的 final config
|
||||
serde_json::to_string(&final_config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
|
||||
}
|
||||
"gemini" => {
|
||||
// Gemini: 只提取 env 字段(与原始备份格式一致)
|
||||
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
|
||||
let env_backup = if let Some(env) = provider.settings_config.get("env") {
|
||||
let env_backup = if let Some(env) = final_config.get("env") {
|
||||
json!({ "env": env })
|
||||
} else {
|
||||
json!({ "env": {} })
|
||||
@@ -1541,7 +1572,7 @@ impl ProxyService {
|
||||
.await
|
||||
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
|
||||
|
||||
log::info!("已更新 {app_type} Live 备份(热切换)");
|
||||
log::info!("已更新 {app_type} Live 备份(热切换,含 common config 合并)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
|
||||
className={isFullHeight ? "flex-1 min-h-0" : ""}
|
||||
/>
|
||||
{language === "json" && (
|
||||
{language === "json" && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api";
|
||||
import { extractDifference, isPlainObject } from "@/utils/configMerge";
|
||||
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
||||
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
|
||||
import {
|
||||
parseGeminiCommonConfigSnippet,
|
||||
mapGeminiWarningToI18n,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
|
||||
interface EditProviderDialogProps {
|
||||
open: boolean;
|
||||
@@ -138,7 +141,7 @@ export function EditProviderDialog({
|
||||
} else {
|
||||
// Show warning toast if keys were filtered
|
||||
if (parseResult.warning) {
|
||||
toast.warning(parseResult.warning);
|
||||
toast.warning(mapGeminiWarningToI18n(parseResult.warning, t));
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -38,7 +38,10 @@ import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import { extractDifference, isPlainObject } from "@/utils/configMerge";
|
||||
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
||||
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
|
||||
import {
|
||||
parseGeminiCommonConfigSnippet,
|
||||
mapGeminiWarningToI18n,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||
import CodexConfigEditor from "./CodexConfigEditor";
|
||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||
@@ -979,7 +982,7 @@ export function ProviderForm({
|
||||
);
|
||||
// Show warning toast if keys were filtered
|
||||
if (warning) {
|
||||
toast.warning(warning);
|
||||
toast.warning(mapGeminiWarningToI18n(warning, t));
|
||||
}
|
||||
if (isPlainObject(envObj) && isPlainObject(commonEnvObj)) {
|
||||
const { customConfig } = extractDifference(envObj, commonEnvObj);
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"actions": "Actions",
|
||||
"deleting": "Deleting..."
|
||||
"deleting": "Deleting...",
|
||||
"readonly": "Read-only"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "Enter API Key",
|
||||
@@ -58,7 +59,12 @@
|
||||
"extractFromCurrent": "Extract from Editor",
|
||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
||||
"extractFailed": "Extract failed: {{error}}",
|
||||
"saveFailed": "Save failed: {{error}}"
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"hidePreview": "Hide merged preview",
|
||||
"showPreview": "Show merged preview",
|
||||
"customConfig": "Custom config (overrides common)",
|
||||
"mergedPreview": "Merged preview (read-only)",
|
||||
"mergedPreviewHint": "Common config + Custom config = Final config"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "View on GitHub",
|
||||
@@ -553,7 +559,8 @@
|
||||
"commonConfigInvalidValues": "Common config snippet values must be strings",
|
||||
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
|
||||
"configMergeFailed": "Config merge failed: {{error}}",
|
||||
"configReplaceFailed": "Config replace failed: {{error}}"
|
||||
"configReplaceFailed": "Config replace failed: {{error}}",
|
||||
"forbiddenKeysWarning": "The following keys were filtered (not allowed in common config): {{keys}}"
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "API Format",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"search": "検索",
|
||||
"reset": "リセット",
|
||||
"actions": "操作",
|
||||
"deleting": "削除中..."
|
||||
"deleting": "削除中...",
|
||||
"readonly": "読み取り専用"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "API Key を入力",
|
||||
@@ -58,7 +59,12 @@
|
||||
"extractFromCurrent": "編集内容から抽出",
|
||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"hidePreview": "マージプレビューを非表示",
|
||||
"showPreview": "マージプレビューを表示",
|
||||
"customConfig": "カスタム設定(共通設定を上書き)",
|
||||
"mergedPreview": "マージプレビュー(読み取り専用)",
|
||||
"mergedPreviewHint": "共通設定 + カスタム設定 = 最終設定"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "GitHub で見る",
|
||||
@@ -553,7 +559,8 @@
|
||||
"commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります",
|
||||
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
||||
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
|
||||
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
|
||||
"configReplaceFailed": "設定の置換に失敗しました: {{error}}",
|
||||
"forbiddenKeysWarning": "以下のキーはフィルタされました(共通設定では許可されていません):{{keys}}"
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "API フォーマット",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"search": "查询",
|
||||
"reset": "重置",
|
||||
"actions": "操作",
|
||||
"deleting": "删除中..."
|
||||
"deleting": "删除中...",
|
||||
"readonly": "只读"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "请输入API Key",
|
||||
@@ -58,7 +59,12 @@
|
||||
"extractFromCurrent": "从编辑内容提取",
|
||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||
"extractFailed": "提取失败: {{error}}",
|
||||
"saveFailed": "保存失败: {{error}}"
|
||||
"saveFailed": "保存失败: {{error}}",
|
||||
"hidePreview": "隐藏合并预览",
|
||||
"showPreview": "显示合并预览",
|
||||
"customConfig": "自定义配置(覆盖通用配置)",
|
||||
"mergedPreview": "合并预览(只读)",
|
||||
"mergedPreviewHint": "通用配置 + 自定义配置 = 最终配置"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "在 GitHub 上查看",
|
||||
@@ -553,7 +559,8 @@
|
||||
"commonConfigInvalidValues": "通用配置片段的值必须是字符串",
|
||||
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
||||
"configMergeFailed": "配置合并失败: {{error}}",
|
||||
"configReplaceFailed": "配置替换失败: {{error}}"
|
||||
"configReplaceFailed": "配置替换失败: {{error}}",
|
||||
"forbiddenKeysWarning": "以下密钥已被自动过滤(禁止在通用配置中设置):{{keys}}"
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "接口格式",
|
||||
|
||||
@@ -73,7 +73,7 @@ export const deepEqual = (a: unknown, b: unknown): boolean => {
|
||||
* - 嵌套对象:递归合并
|
||||
* - 数组:source 完全替换 target(不做元素级合并)
|
||||
* - 原始值:source 覆盖 target
|
||||
* - undefined:不覆盖
|
||||
* - undefined/null:不覆盖(与后端 config_merge.rs 保持一致)
|
||||
*/
|
||||
export const deepMerge = <T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
@@ -85,8 +85,8 @@ export const deepMerge = <T extends Record<string, unknown>>(
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
|
||||
// undefined 不覆盖
|
||||
if (sourceValue === undefined) {
|
||||
// undefined 和 null 都不覆盖(与后端保持一致)
|
||||
if (sourceValue === undefined || sourceValue === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1201,3 +1201,26 @@ export function parseGeminiCommonConfigSnippet(
|
||||
warning: warnings.length > 0 ? warnings.join("; ") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Gemini common config warning to i18n-friendly message.
|
||||
*
|
||||
* @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 {
|
||||
if (warning.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
|
||||
// Extract key list: "GEMINI_CONFIG_FORBIDDEN_KEYS: KEY1, KEY2" -> "KEY1, KEY2"
|
||||
const keys = warning.replace(
|
||||
`${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: `,
|
||||
"",
|
||||
);
|
||||
return t("geminiConfig.forbiddenKeysWarning", { keys });
|
||||
}
|
||||
// Other warnings: return as-is
|
||||
return warning;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user