feat: add Claude common config snippet functionality

- Create useCommonConfigSnippet hook to manage common config state
- Create CommonConfigEditor component with modal for editing
- Support merging/removing common config snippets from provider configs
- Persist common config to localStorage for reuse across providers
- Auto-detect if provider config contains common snippet
- Replace JSON editor with CommonConfigEditor in ProviderForm
This commit is contained in:
Jason
2025-10-16 20:32:11 +08:00
parent 74afca7b58
commit 856beb3b70
4 changed files with 373 additions and 35 deletions

View File

@@ -0,0 +1,145 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import JsonEditor from "@/components/JsonEditor";
import { useTheme } from "@/components/theme-provider";
import { useMemo } from "react";
interface CommonConfigEditorProps {
value: string;
onChange: (value: string) => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
onEditClick: () => void;
isModalOpen: boolean;
onModalClose: () => void;
}
export function CommonConfigEditor({
value,
onChange,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
onEditClick,
isModalOpen,
onModalClose,
}: CommonConfigEditorProps) {
const { t } = useTranslation();
const { theme } = useTheme();
const isDarkMode = useMemo(() => {
if (theme === "dark") return true;
if (theme === "light") return false;
return typeof window !== "undefined"
? window.document.documentElement.classList.contains("dark")
: false;
}, [theme]);
return (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="settingsConfig">
{t("provider.configJson", { defaultValue: "配置 JSON" })}
</Label>
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
id="useCommonConfig"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>
{t("claudeConfig.writeCommonConfig", {
defaultValue: "写入通用配置",
})}
</span>
</label>
</div>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditClick}
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{t("claudeConfig.editCommonConfig", {
defaultValue: "编辑通用配置",
})}
</button>
</div>
{commonConfigError && !isModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<div className="rounded-md border">
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
}
}`}
darkMode={isDarkMode}
rows={14}
showValidation
/>
</div>
<p className="text-xs text-muted-foreground">
{t("claudeConfig.fullSettingsHint", {
defaultValue: "请填写完整的 Claude Code 配置",
})}
</p>
</div>
<Dialog open={isModalOpen} onOpenChange={(open) => !open && onModalClose()}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{t("claudeConfig.editCommonConfigTitle", {
defaultValue: "编辑通用配置片段",
})}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground">
{t("claudeConfig.commonConfigHint", {
defaultValue:
"通用配置片段将合并到所有启用它的供应商配置中",
})}
</p>
<div className="rounded-md border">
<JsonEditor
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
darkMode={isDarkMode}
rows={12}
/>
</div>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -12,8 +12,6 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useTheme } from "@/components/theme-provider";
import JsonEditor from "@/components/JsonEditor";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppType } from "@/lib/api"; import type { AppType } from "@/lib/api";
import type { ProviderCategory, CustomEndpoint } from "@/types"; import type { ProviderCategory, CustomEndpoint } from "@/types";
@@ -27,6 +25,7 @@ import ApiKeyInput from "@/components/ProviderForm/ApiKeyInput";
import EndpointSpeedTest from "@/components/ProviderForm/EndpointSpeedTest"; import EndpointSpeedTest from "@/components/ProviderForm/EndpointSpeedTest";
import CodexConfigEditor from "@/components/ProviderForm/CodexConfigEditor"; import CodexConfigEditor from "@/components/ProviderForm/CodexConfigEditor";
import KimiModelSelector from "@/components/ProviderForm/KimiModelSelector"; import KimiModelSelector from "@/components/ProviderForm/KimiModelSelector";
import { CommonConfigEditor } from "./CommonConfigEditor";
import { Zap } from "lucide-react"; import { Zap } from "lucide-react";
import { import {
useProviderCategory, useProviderCategory,
@@ -38,6 +37,7 @@ import {
useCustomEndpoints, useCustomEndpoints,
useKimiModelSelector, useKimiModelSelector,
useTemplateValues, useTemplateValues,
useCommonConfigSnippet,
} from "./hooks"; } from "./hooks";
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2); const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2);
@@ -68,7 +68,6 @@ export function ProviderForm({
initialData, initialData,
}: ProviderFormProps) { }: ProviderFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useTheme();
const isEditMode = Boolean(initialData); const isEditMode = Boolean(initialData);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>( const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
@@ -172,14 +171,6 @@ export function ProviderForm({
form.reset(defaultValues); form.reset(defaultValues);
}, [defaultValues, form]); }, [defaultValues, form]);
const isDarkMode = useMemo(() => {
if (theme === "dark") return true;
if (theme === "light") return false;
return typeof window !== "undefined"
? window.document.documentElement.classList.contains("dark")
: false;
}, [theme]);
const presetCategoryLabels: Record<string, string> = useMemo( const presetCategoryLabels: Record<string, string> = useMemo(
() => ({ () => ({
official: t("providerPreset.categoryOfficial", { official: t("providerPreset.categoryOfficial", {
@@ -243,6 +234,21 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config), onConfigChange: (config) => form.setValue("settingsConfig", config),
}); });
// 使用通用配置片段 hook (仅 Claude 模式)
const {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
} = useCommonConfigSnippet({
settingsConfig: form.watch("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
initialData: appType === "claude" ? initialData : undefined,
});
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
const handleSubmit = (values: ProviderFormData) => { const handleSubmit = (values: ProviderFormData) => {
// 验证模板变量(仅 Claude 模式) // 验证模板变量(仅 Claude 模式)
if (appType === "claude" && templateValueEntries.length > 0) { if (appType === "claude" && templateValueEntries.length > 0) {
@@ -790,7 +796,7 @@ export function ProviderForm({
/> />
)} )}
{/* 配置编辑器Claude 使用 JSON 编辑器Codex 使用专用编辑器 */} {/* 配置编辑器Claude 使用通用配置编辑器Codex 使用专用编辑器 */}
{appType === "codex" ? ( {appType === "codex" ? (
<CodexConfigEditor <CodexConfigEditor
authValue={codexAuth} authValue={codexAuth}
@@ -805,29 +811,17 @@ export function ProviderForm({
authError={codexAuthError} authError={codexAuthError}
/> />
) : ( ) : (
<FormField <CommonConfigEditor
control={form.control} value={form.watch("settingsConfig")}
name="settingsConfig" onChange={(value) => form.setValue("settingsConfig", value)}
render={({ field }) => ( useCommonConfig={useCommonConfig}
<FormItem> onCommonConfigToggle={handleCommonConfigToggle}
<FormLabel> commonConfigSnippet={commonConfigSnippet}
{t("provider.configJson", { defaultValue: "配置 JSON" })} onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
</FormLabel> commonConfigError={commonConfigError}
<FormControl> onEditClick={() => setIsCommonConfigModalOpen(true)}
<div className="rounded-md border"> isModalOpen={isCommonConfigModalOpen}
<JsonEditor onModalClose={() => setIsCommonConfigModalOpen(false)}
value={field.value}
onChange={field.onChange}
placeholder={CLAUDE_DEFAULT_CONFIG}
darkMode={isDarkMode}
rows={14}
showValidation
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
)} )}

View File

@@ -7,3 +7,4 @@ export { useApiKeyLink } from "./useApiKeyLink";
export { useCustomEndpoints } from "./useCustomEndpoints"; export { useCustomEndpoints } from "./useCustomEndpoints";
export { useKimiModelSelector } from "./useKimiModelSelector"; export { useKimiModelSelector } from "./useKimiModelSelector";
export { useTemplateValues } from "./useTemplateValues"; export { useTemplateValues } from "./useTemplateValues";
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";

View File

@@ -0,0 +1,198 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
updateCommonConfigSnippet,
hasCommonConfigSnippet,
validateJsonConfig,
} from "@/utils/providerConfigUtils";
const COMMON_CONFIG_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>;
};
}
/**
* 管理 Claude 通用配置片段
*/
export function useCommonConfigSnippet({
settingsConfig,
onConfigChange,
initialData,
}: UseCommonConfigSnippetProps) {
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = 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("");
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (initialData) {
const configString = JSON.stringify(
initialData.settingsConfig,
null,
2,
);
const hasCommon = hasCommonConfigSnippet(
configString,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}
}, [initialData, commonConfigSnippet]);
// 同步本地存储的通用配置片段
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 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("");
if (useCommonConfig) {
const { updatedConfig } = updateCommonConfigSnippet(
settingsConfig,
previousSnippet,
false,
);
onConfigChange(updatedConfig);
setUseCommonConfig(false);
}
return;
}
// 验证JSON格式
const validationError = validateJsonConfig(value, "通用配置片段");
if (validationError) {
setCommonConfigError(validationError);
} else {
setCommonConfigError("");
}
// 若当前启用通用配置且格式正确,需要替换为最新片段
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 (isUpdatingFromCommonConfig.current) {
return;
}
const hasCommon = hasCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}, [settingsConfig, commonConfigSnippet]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
};
}