From 7374b934c7a954b39928dc08a806eb9c67ee2a9a Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 16 Sep 2025 22:59:00 +0800 Subject: [PATCH] refactor: replace co-authored setting with flexible common config snippet feature - Replace single "disable co-authored" checkbox with universal "common config snippet" functionality - Add localStorage persistence for common config snippets - Implement deep merge/remove operations for complex JSON structures - Add modal editor for managing common config snippets - Optimize performance with custom deepClone function instead of JSON.parse/stringify - Fix deep remove logic to only delete matching values - Improve error handling and validation for JSON snippets --- src/components/ProviderForm.tsx | 168 +++++++++++++++--- .../ProviderForm/ClaudeConfigEditor.tsx | 94 +++++++++- src/utils/providerConfigUtils.ts | 163 ++++++++++++++--- 3 files changed, 366 insertions(+), 59 deletions(-) diff --git a/src/components/ProviderForm.tsx b/src/components/ProviderForm.tsx index bdddf6e..50851b7 100644 --- a/src/components/ProviderForm.tsx +++ b/src/components/ProviderForm.tsx @@ -2,8 +2,8 @@ import React, { useState, useEffect } from "react"; import { Provider, ProviderCategory } from "../types"; import { AppType } from "../lib/tauri-api"; import { - updateCoAuthoredSetting, - checkCoAuthoredSetting, + updateCommonConfigSnippet, + hasCommonConfigSnippet, getApiKeyFromConfig, hasApiKeyField, setApiKeyInConfig, @@ -18,6 +18,11 @@ import KimiModelSelector from "./ProviderForm/KimiModelSelector"; import { X, AlertCircle, Save } from "lucide-react"; // 分类仅用于控制少量交互(如官方禁用 API Key),不显示介绍组件 +const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet"; +const DEFAULT_COMMON_CONFIG_SNIPPET = `{ + "includeCoAuthoredBy": false +}`; + interface ProviderFormProps { appType?: AppType; title: string; @@ -85,7 +90,22 @@ const ProviderForm: React.FC = ({ }, [isCodex, initialData]); const [error, setError] = useState(""); - const [disableCoAuthored, setDisableCoAuthored] = useState(false); + const [useCommonConfig, setUseCommonConfig] = useState(false); + const [commonConfigSnippet, setCommonConfigSnippet] = useState(() => { + if (typeof window === "undefined") { + return DEFAULT_COMMON_CONFIG_SNIPPET; + } + try { + const stored = window.localStorage.getItem(COMMON_CONFIG_STORAGE_KEY); + if (stored && stored.trim()) { + return stored; + } + } catch { + // ignore localStorage 读取失败 + } + return DEFAULT_COMMON_CONFIG_SNIPPET; + }); + const [commonConfigError, setCommonConfigError] = useState(""); // -1 表示自定义,null 表示未选择,>= 0 表示预设索引 const [selectedPreset, setSelectedPreset] = useState( showPresets ? -1 : null, @@ -124,12 +144,15 @@ const ProviderForm: React.FC = ({ } }, []); // 只在组件挂载时执行一次 - // 初始化时检查禁用签名状态 + // 初始化时检查通用配置片段 useEffect(() => { if (initialData) { const configString = JSON.stringify(initialData.settingsConfig, null, 2); - const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString); - setDisableCoAuthored(hasCoAuthoredDisabled); + const hasCommon = hasCommonConfigSnippet( + configString, + commonConfigSnippet, + ); + setUseCommonConfig(hasCommon); // 初始化模型配置(编辑模式) if ( @@ -152,7 +175,7 @@ const ProviderForm: React.FC = ({ } } } - }, [initialData]); + }, [initialData, commonConfigSnippet]); // 当选择预设变化时,同步类别 useEffect(() => { @@ -178,6 +201,23 @@ const ProviderForm: React.FC = ({ } }, [showPresets, isCodex, selectedPreset, selectedCodexPreset]); + // 同步本地存储的通用配置片段 + useEffect(() => { + if (typeof window === "undefined") return; + try { + if (commonConfigSnippet.trim()) { + window.localStorage.setItem( + COMMON_CONFIG_STORAGE_KEY, + commonConfigSnippet, + ); + } else { + window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY); + } + } catch { + // ignore + } + }, [commonConfigSnippet]); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -254,8 +294,8 @@ const ProviderForm: React.FC = ({ if (name === "settingsConfig") { // 同时检查并同步选择框状态 - const hasCoAuthoredDisabled = checkCoAuthoredSetting(value); - setDisableCoAuthored(hasCoAuthoredDisabled); + const hasCommon = hasCommonConfigSnippet(value, commonConfigSnippet); + setUseCommonConfig(hasCommon); // 同步 API Key 输入框显示与值 const parsedKey = getApiKeyFromConfig(value); @@ -274,19 +314,82 @@ const ProviderForm: React.FC = ({ } }; - // 处理选择框变化 - const handleCoAuthoredToggle = (checked: boolean) => { - setDisableCoAuthored(checked); - - // 更新JSON配置 - const updatedConfig = updateCoAuthoredSetting( + // 处理通用配置开关 + const handleCommonConfigToggle = (checked: boolean) => { + const { updatedConfig, error: snippetError } = updateCommonConfigSnippet( formData.settingsConfig, + commonConfigSnippet, checked, ); - setFormData({ - ...formData, + + if (snippetError) { + setCommonConfigError(snippetError); + setUseCommonConfig(false); + return; + } + + setCommonConfigError(""); + setUseCommonConfig(checked); + setFormData((prev) => ({ + ...prev, settingsConfig: updatedConfig, - }); + })); + }; + + const handleCommonConfigSnippetChange = (value: string) => { + const previousSnippet = commonConfigSnippet; + setCommonConfigSnippet(value); + + if (!value.trim()) { + setCommonConfigError(""); + if (useCommonConfig) { + const { updatedConfig } = updateCommonConfigSnippet( + formData.settingsConfig, + previousSnippet, + false, + ); + setFormData((prev) => ({ + ...prev, + settingsConfig: updatedConfig, + })); + setUseCommonConfig(false); + } + return; + } + + // 验证JSON格式 + let isValidJson = false; + try { + JSON.parse(value); + isValidJson = true; + setCommonConfigError(""); + } catch (err) { + setCommonConfigError("通用配置片段格式错误,需为合法 JSON"); + } + + // 若当前启用通用配置且格式正确,需要替换为最新片段 + if (useCommonConfig && isValidJson) { + const removeResult = updateCommonConfigSnippet( + formData.settingsConfig, + previousSnippet, + false, + ); + const addResult = updateCommonConfigSnippet( + removeResult.updatedConfig, + value, + true, + ); + + if (addResult.error) { + setCommonConfigError(addResult.error); + return; + } + + setFormData((prev) => ({ + ...prev, + settingsConfig: addResult.updatedConfig, + })); + } }; const applyPreset = (preset: (typeof providerPresets)[0], index: number) => { @@ -308,9 +411,13 @@ const ProviderForm: React.FC = ({ setApiKey(""); setBaseUrl(""); // 清空基础 URL - // 同步选择框状态 - const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString); - setDisableCoAuthored(hasCoAuthoredDisabled); + // 同步通用配置状态 + const hasCommon = hasCommonConfigSnippet( + configString, + commonConfigSnippet, + ); + setUseCommonConfig(hasCommon); + setCommonConfigError(""); // 如果预设包含模型配置,初始化模型输入框 if (preset.settingsConfig && typeof preset.settingsConfig === "object") { @@ -355,7 +462,8 @@ const ProviderForm: React.FC = ({ }); setApiKey(""); setBaseUrl("https://your-api-endpoint.com"); // 设置默认的基础 URL - setDisableCoAuthored(false); + setUseCommonConfig(false); + setCommonConfigError(""); setClaudeModel(""); setClaudeSmallFastModel(""); setKimiAnthropicModel(""); @@ -417,9 +525,12 @@ const ProviderForm: React.FC = ({ settingsConfig: configString, })); - // 同步选择框状态 - const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString); - setDisableCoAuthored(hasCoAuthoredDisabled); + // 同步通用配置开关 + const hasCommon = hasCommonConfigSnippet( + configString, + commonConfigSnippet, + ); + setUseCommonConfig(hasCommon); }; // 处理基础 URL 变化 @@ -925,8 +1036,11 @@ const ProviderForm: React.FC = ({ target: { name: "settingsConfig", value }, } as React.ChangeEvent) } - disableCoAuthored={disableCoAuthored} - onCoAuthoredToggle={handleCoAuthoredToggle} + useCommonConfig={useCommonConfig} + onCommonConfigToggle={handleCommonConfigToggle} + commonConfigSnippet={commonConfigSnippet} + onCommonConfigSnippetChange={handleCommonConfigSnippetChange} + commonConfigError={commonConfigError} /> )} diff --git a/src/components/ProviderForm/ClaudeConfigEditor.tsx b/src/components/ProviderForm/ClaudeConfigEditor.tsx index 2545ed2..3608dc4 100644 --- a/src/components/ProviderForm/ClaudeConfigEditor.tsx +++ b/src/components/ProviderForm/ClaudeConfigEditor.tsx @@ -4,17 +4,24 @@ import JsonEditor from "../JsonEditor"; interface ClaudeConfigEditorProps { value: string; onChange: (value: string) => void; - disableCoAuthored: boolean; - onCoAuthoredToggle: (checked: boolean) => void; + useCommonConfig: boolean; + onCommonConfigToggle: (checked: boolean) => void; + commonConfigSnippet: string; + onCommonConfigSnippetChange: (value: string) => void; + commonConfigError: string; } const ClaudeConfigEditor: React.FC = ({ value, onChange, - disableCoAuthored, - onCoAuthoredToggle, + useCommonConfig, + onCommonConfigToggle, + commonConfigSnippet, + onCommonConfigSnippetChange, + commonConfigError, }) => { const [isDarkMode, setIsDarkMode] = useState(false); + const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); useEffect(() => { // 检测暗色模式 @@ -40,6 +47,16 @@ const ClaudeConfigEditor: React.FC = ({ return () => observer.disconnect(); }, []); + + useEffect(() => { + if (commonConfigError && !isCommonConfigModalOpen) { + setIsCommonConfigModalOpen(true); + } + }, [commonConfigError, isCommonConfigModalOpen]); + + const closeModal = () => { + setIsCommonConfigModalOpen(false); + }; return (
@@ -52,13 +69,27 @@ const ClaudeConfigEditor: React.FC = ({
+
+ +
+ {commonConfigError && !isCommonConfigModalOpen && ( +

+ {commonConfigError} +

+ )} = ({

完整的 Claude Code settings.json 配置内容

+ {isCommonConfigModalOpen && ( +
+
+
+
+
+

+ 编辑通用配置片段 +

+

+ 该片段会在勾选“写入通用配置”时合并到 settings.json 中 +

+
+ +
+
+ + {commonConfigError && ( +

+ {commonConfigError} +

+ )} +
+
+ +
+
+
+ )}
); }; diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 4131be3..186c533 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -1,33 +1,146 @@ // 供应商配置处理工具函数 -// 处理includeCoAuthoredBy字段的添加/删除 -export const updateCoAuthoredSetting = ( - jsonString: string, - disable: boolean, -): string => { - try { - const config = JSON.parse(jsonString); - - if (disable) { - // 添加或更新includeCoAuthoredBy字段 - config.includeCoAuthoredBy = false; - } else { - // 删除includeCoAuthoredBy字段 - delete config.includeCoAuthoredBy; - } - - return JSON.stringify(config, null, 2); - } catch (err) { - // 如果JSON解析失败,返回原始字符串 - return jsonString; - } +const isPlainObject = (value: unknown): value is Record => { + return Object.prototype.toString.call(value) === "[object Object]"; }; -// 从JSON配置中检查是否包含includeCoAuthoredBy设置 -export const checkCoAuthoredSetting = (jsonString: string): boolean => { +const deepMerge = ( + target: Record, + source: Record, +): Record => { + 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, source: Record) => { + 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 = (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; +} + +// 将通用配置片段写入/移除 settingsConfig +export const updateCommonConfigSnippet = ( + jsonString: string, + snippetString: string, + enabled: boolean, +): UpdateCommonConfigResult => { + let config: Record; try { - const config = JSON.parse(jsonString); - return config.includeCoAuthoredBy === false; + config = jsonString ? JSON.parse(jsonString) : {}; + } catch (err) { + return { + updatedConfig: jsonString, + error: "配置 JSON 解析失败,无法写入通用配置", + }; + } + + if (!snippetString.trim()) { + return { + updatedConfig: JSON.stringify(config, null, 2), + }; + } + + let snippet: Record; + try { + const parsed = JSON.parse(snippetString); + if (!isPlainObject(parsed)) { + return { + updatedConfig: JSON.stringify(config, null, 2), + error: "通用配置片段必须是 JSON 对象", + }; + } + snippet = parsed; + } catch (err) { + return { + updatedConfig: JSON.stringify(config, null, 2), + error: "通用配置片段格式错误,需为合法 JSON", + }; + } + + 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; }