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
This commit is contained in:
Jason
2025-09-16 22:59:00 +08:00
parent d9d7c5c342
commit 7374b934c7
3 changed files with 366 additions and 59 deletions

View File

@@ -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<ProviderFormProps> = ({
}, [isCodex, initialData]);
const [error, setError] = useState("");
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippet] = useState<string>(() => {
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<number | null>(
showPresets ? -1 : null,
@@ -124,12 +144,15 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}, []); // 只在组件挂载时执行一次
// 初始化时检查禁用签名状态
// 初始化时检查通用配置片段
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<ProviderFormProps> = ({
}
}
}
}, [initialData]);
}, [initialData, commonConfigSnippet]);
// 当选择预设变化时,同步类别
useEffect(() => {
@@ -178,6 +201,23 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}, [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<ProviderFormProps> = ({
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<ProviderFormProps> = ({
}
};
// 处理选择框变化
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<ProviderFormProps> = ({
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<ProviderFormProps> = ({
});
setApiKey("");
setBaseUrl("https://your-api-endpoint.com"); // 设置默认的基础 URL
setDisableCoAuthored(false);
setUseCommonConfig(false);
setCommonConfigError("");
setClaudeModel("");
setClaudeSmallFastModel("");
setKimiAnthropicModel("");
@@ -417,9 +525,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
settingsConfig: configString,
}));
// 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
// 同步通用配置开关
const hasCommon = hasCommonConfigSnippet(
configString,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
};
// 处理基础 URL 变化
@@ -925,8 +1036,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
target: { name: "settingsConfig", value },
} as React.ChangeEvent<HTMLTextAreaElement>)
}
disableCoAuthored={disableCoAuthored}
onCoAuthoredToggle={handleCoAuthoredToggle}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={handleCommonConfigToggle}
commonConfigSnippet={commonConfigSnippet}
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
commonConfigError={commonConfigError}
/>
</>
)}

View File

@@ -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<ClaudeConfigEditorProps> = ({
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<ClaudeConfigEditorProps> = ({
return () => observer.disconnect();
}, []);
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
}, [commonConfigError, isCommonConfigModalOpen]);
const closeModal = () => {
setIsCommonConfigModalOpen(false);
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -52,13 +69,27 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => onCoAuthoredToggle(e.target.checked)}
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
Claude Code
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={() => setIsCommonConfigModalOpen(true)}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
</button>
</div>
{commonConfigError && !isCommonConfigModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<JsonEditor
value={value}
onChange={onChange}
@@ -74,6 +105,55 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
<p className="text-xs text-gray-500 dark:text-gray-400">
Claude Code settings.json
</p>
{isCommonConfigModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={closeModal}
/>
<div className="relative z-10 w-full max-w-2xl mx-4 bg-white dark:bg-gray-900 rounded-xl shadow-lg border border-gray-200 dark:border-gray-800">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-800">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
settings.json
</p>
</div>
<button
type="button"
onClick={closeModal}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
</button>
</div>
<div className="px-5 py-4 space-y-2">
<JsonEditor
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
darkMode={isDarkMode}
rows={12}
/>
{commonConfigError && (
<p className="text-xs text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-950">
<button
type="button"
onClick={closeModal}
className="px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -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<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
// 从JSON配置中检查是否包含includeCoAuthoredBy设置
export const checkCoAuthoredSetting = (jsonString: string): boolean => {
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;
}
// 将通用配置片段写入/移除 settingsConfig
export const updateCommonConfigSnippet = (
jsonString: string,
snippetString: string,
enabled: boolean,
): UpdateCommonConfigResult => {
let config: Record<string, any>;
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<string, any>;
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;
}