mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
refactor(provider): switch from full config overwrite to partial key-field merging (#1098)
* refactor(provider): switch from full config overwrite to partial key-field merging Replace the provider switching mechanism for Claude/Codex/Gemini from full settings_config overwrite to partial key-field replacement, preserving user's non-provider settings (plugins, MCP, permissions, etc.) across switches. - Add write_live_partial() with per-app implementations for Claude (JSON env merge), Codex (auth replace + TOML partial merge), and Gemini (env merge) - Add backfill_key_fields() to extract only provider-specific fields when saving live config back to provider entries - Update switch_normal, sync_current_to_live, add, update to use partial merge - Remove common config snippet feature for Claude/Codex/Gemini (no longer needed with partial merging); preserve OMO common config - Delete 6 frontend files (3 components + 3 hooks), clean up 11 modified files - Remove backend extract_common_config_* methods, 3 Tauri commands, CommonConfigSnippets struct, and related migration code - Update integration tests to validate key-field-only backfill behavior * refactor(cleanup): remove dead code and redundant MCP sync after partial-merge refactor - Remove ConfigService legacy full-overwrite sync methods (~150 lines) - Remove redundant McpService::sync_all_enabled from switch_normal - Switch proxy fallback recovery from write_live_snapshot to write_live_partial - Remove dead ProviderService::write_gemini_live wrapper - Update tests to reflect partial-merge behavior (MCP preserved, not re-synced) * feat(claude): add Quick Toggles for common Claude Code preferences Add checkbox toggles for hideAttribution, alwaysThinking, and enableTeammates that write directly to the live settings file via RFC 7396 JSON Merge Patch. Mirror changes to the form editor using form.watch for reactive updates. * fix(provider): add missing key fields to partial-merge constants Add provider-specific fields verified against official docs to prevent key residue or loss during provider switching: - Claude: CLAUDE_CODE_SUBAGENT_MODEL (env), model (top-level) - Codex: review_model, plan_mode_reasoning_effort - Gemini: GOOGLE_API_KEY (official alternative to GEMINI_API_KEY) * fix(provider): expand partial-merge key fields for Bedrock, Vertex, Foundry and behavior settings Add missing env/top-level fields to CLAUDE_KEY_ENV_FIELDS and CLAUDE_KEY_TOP_LEVEL so that provider switching correctly replaces (and clears) credentials and flags for AWS Bedrock, Google Vertex AI, Microsoft Foundry, and provider behavior overrides like max output tokens and prompt caching. * feat(provider): add auth field selector for Claude providers (AUTH_TOKEN / API_KEY) Allow users to choose between ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY when creating or editing custom Claude providers, persisted in meta.apiKeyField. * refactor(preset): remove AiHubMix hardcoded API_KEY in favor of generic auth selector AiHubMix was the only preset that hardcoded ANTHROPIC_API_KEY before the generic auth field selector was introduced. Now that users can freely choose between AUTH_TOKEN and API_KEY via the UI, remove the special-case and default AiHubMix to the standard ANTHROPIC_AUTH_TOKEN.
This commit is contained in:
@@ -3,86 +3,6 @@
|
||||
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||||
import { normalizeQuotes } from "@/utils/textNormalization";
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
||||
return Object.prototype.toString.call(value) === "[object Object]";
|
||||
};
|
||||
|
||||
const deepMerge = (
|
||||
target: Record<string, any>,
|
||||
source: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (isPlainObject(value)) {
|
||||
if (!isPlainObject(target[key])) {
|
||||
target[key] = {};
|
||||
}
|
||||
deepMerge(target[key], value);
|
||||
} else {
|
||||
// 直接覆盖非对象字段(数组/基础类型)
|
||||
target[key] = value;
|
||||
}
|
||||
});
|
||||
return target;
|
||||
};
|
||||
|
||||
const deepRemove = (
|
||||
target: Record<string, any>,
|
||||
source: Record<string, any>,
|
||||
) => {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (!(key in target)) return;
|
||||
|
||||
if (isPlainObject(value) && isPlainObject(target[key])) {
|
||||
// 只移除完全匹配的嵌套属性
|
||||
deepRemove(target[key], value);
|
||||
if (Object.keys(target[key]).length === 0) {
|
||||
delete target[key];
|
||||
}
|
||||
} else if (isSubset(target[key], value)) {
|
||||
// 只有当值完全匹配时才删除
|
||||
delete target[key];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isSubset = (target: any, source: any): boolean => {
|
||||
if (isPlainObject(source)) {
|
||||
if (!isPlainObject(target)) return false;
|
||||
return Object.entries(source).every(([key, value]) =>
|
||||
isSubset(target[key], value),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
if (!Array.isArray(target) || target.length !== source.length) return false;
|
||||
return source.every((item, index) => isSubset(target[index], item));
|
||||
}
|
||||
|
||||
return target === source;
|
||||
};
|
||||
|
||||
// 深拷贝函数
|
||||
const deepClone = <T>(obj: T): T => {
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
||||
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
|
||||
if (obj instanceof Object) {
|
||||
const clonedObj = {} as T;
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
export interface UpdateCommonConfigResult {
|
||||
updatedConfig: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 验证JSON配置格式
|
||||
export const validateJsonConfig = (
|
||||
value: string,
|
||||
@@ -102,69 +22,6 @@ export const validateJsonConfig = (
|
||||
}
|
||||
};
|
||||
|
||||
// 将通用配置片段写入/移除 settingsConfig
|
||||
export const updateCommonConfigSnippet = (
|
||||
jsonString: string,
|
||||
snippetString: string,
|
||||
enabled: boolean,
|
||||
): UpdateCommonConfigResult => {
|
||||
let config: Record<string, any>;
|
||||
try {
|
||||
config = jsonString ? JSON.parse(jsonString) : {};
|
||||
} catch (err) {
|
||||
return {
|
||||
updatedConfig: jsonString,
|
||||
error: "配置 JSON 解析失败,无法写入通用配置",
|
||||
};
|
||||
}
|
||||
|
||||
if (!snippetString.trim()) {
|
||||
return {
|
||||
updatedConfig: JSON.stringify(config, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
// 使用统一的验证函数
|
||||
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
|
||||
if (snippetError) {
|
||||
return {
|
||||
updatedConfig: JSON.stringify(config, null, 2),
|
||||
error: snippetError,
|
||||
};
|
||||
}
|
||||
|
||||
const snippet = JSON.parse(snippetString) as Record<string, any>;
|
||||
|
||||
if (enabled) {
|
||||
const merged = deepMerge(deepClone(config), snippet);
|
||||
return {
|
||||
updatedConfig: JSON.stringify(merged, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
const cloned = deepClone(config);
|
||||
deepRemove(cloned, snippet);
|
||||
return {
|
||||
updatedConfig: JSON.stringify(cloned, null, 2),
|
||||
};
|
||||
};
|
||||
|
||||
// 检查当前配置是否已包含通用配置片段
|
||||
export const hasCommonConfigSnippet = (
|
||||
jsonString: string,
|
||||
snippetString: string,
|
||||
): boolean => {
|
||||
try {
|
||||
if (!snippetString.trim()) return false;
|
||||
const config = jsonString ? JSON.parse(jsonString) : {};
|
||||
const snippet = JSON.parse(snippetString);
|
||||
if (!isPlainObject(snippet)) return false;
|
||||
return isSubset(config, snippet);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 读取配置中的 API Key(支持 Claude, Codex, Gemini)
|
||||
export const getApiKeyFromConfig = (
|
||||
jsonString: string,
|
||||
@@ -278,9 +135,13 @@ export const hasApiKeyField = (
|
||||
export const setApiKeyInConfig = (
|
||||
jsonString: string,
|
||||
apiKey: string,
|
||||
options: { createIfMissing?: boolean; appType?: string } = {},
|
||||
options: {
|
||||
createIfMissing?: boolean;
|
||||
appType?: string;
|
||||
apiKeyField?: string;
|
||||
} = {},
|
||||
): string => {
|
||||
const { createIfMissing = false, appType } = options;
|
||||
const { createIfMissing = false, appType, apiKeyField } = options;
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
if (!config.env) {
|
||||
@@ -313,13 +174,13 @@ export const setApiKeyInConfig = (
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段)
|
||||
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则使用 apiKeyField 指定的字段名)
|
||||
if ("ANTHROPIC_AUTH_TOKEN" in env) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
} else if ("ANTHROPIC_API_KEY" in env) {
|
||||
env.ANTHROPIC_API_KEY = apiKey;
|
||||
} else if (createIfMissing) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
env[apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"] = apiKey;
|
||||
} else {
|
||||
return jsonString;
|
||||
}
|
||||
@@ -329,85 +190,6 @@ export const setApiKeyInConfig = (
|
||||
}
|
||||
};
|
||||
|
||||
// ========== TOML Config Utilities ==========
|
||||
|
||||
export interface UpdateTomlCommonConfigResult {
|
||||
updatedConfig: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 保存之前的通用配置片段,用于替换操作
|
||||
let previousCommonSnippet = "";
|
||||
|
||||
// 将通用配置片段写入/移除 TOML 配置
|
||||
export const updateTomlCommonConfigSnippet = (
|
||||
tomlString: string,
|
||||
snippetString: string,
|
||||
enabled: boolean,
|
||||
): UpdateTomlCommonConfigResult => {
|
||||
if (!snippetString.trim()) {
|
||||
// 如果片段为空,直接返回原始配置
|
||||
return {
|
||||
updatedConfig: tomlString,
|
||||
};
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// 添加通用配置
|
||||
// 先移除旧的通用配置(如果有)
|
||||
let updatedConfig = tomlString;
|
||||
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
|
||||
updatedConfig = tomlString.replace(previousCommonSnippet, "");
|
||||
}
|
||||
|
||||
// 在文件末尾添加新的通用配置
|
||||
// 确保有适当的换行
|
||||
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
|
||||
updatedConfig =
|
||||
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
|
||||
|
||||
// 保存当前通用配置片段
|
||||
previousCommonSnippet = snippetString;
|
||||
|
||||
return {
|
||||
updatedConfig: updatedConfig.trim() + "\n",
|
||||
};
|
||||
} else {
|
||||
// 移除通用配置
|
||||
if (tomlString.includes(snippetString)) {
|
||||
const updatedConfig = tomlString.replace(snippetString, "");
|
||||
// 清理多余的空行
|
||||
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
// 清空保存的状态
|
||||
previousCommonSnippet = "";
|
||||
|
||||
return {
|
||||
updatedConfig: cleaned ? cleaned + "\n" : "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
updatedConfig: tomlString,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 检查 TOML 配置是否已包含通用配置片段
|
||||
export const hasTomlCommonConfigSnippet = (
|
||||
tomlString: string,
|
||||
snippetString: string,
|
||||
): boolean => {
|
||||
if (!snippetString.trim()) return false;
|
||||
|
||||
// 简单检查配置是否包含片段内容
|
||||
// 去除空白字符后比较,避免格式差异影响
|
||||
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
|
||||
|
||||
return normalizeWhitespace(tomlString).includes(
|
||||
normalizeWhitespace(snippetString),
|
||||
);
|
||||
};
|
||||
|
||||
// ========== Codex base_url utils ==========
|
||||
|
||||
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
||||
|
||||
Reference in New Issue
Block a user