From d86994eb7ee37dca10e13884387d27e7d340d81c Mon Sep 17 00:00:00 2001 From: Lakr <25259084+Lakr233@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:14:35 +0800 Subject: [PATCH] feat: support kat-coder & template value (#77) --- .gitignore | 1 + src/components/ProviderForm.tsx | 265 ++++++++++++++++++++++++++++++- src/config/providerPresets.ts | 31 ++++ src/utils/providerConfigUtils.ts | 47 ++++++ 4 files changed, 341 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 2ce1d48..0d771aa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ release/ .npmrc CLAUDE.md AGENTS.md +/.claude diff --git a/src/components/ProviderForm.tsx b/src/components/ProviderForm.tsx index 38959f9..2cde773 100644 --- a/src/components/ProviderForm.tsx +++ b/src/components/ProviderForm.tsx @@ -10,8 +10,10 @@ import { updateTomlCommonConfigSnippet, hasTomlCommonConfigSnippet, validateJsonConfig, + applyTemplateValues, } from "../utils/providerConfigUtils"; import { providerPresets } from "../config/providerPresets"; +import type { TemplateValueConfig } from "../config/providerPresets"; import { codexProviderPresets, generateThirdPartyAuth, @@ -26,6 +28,136 @@ import { X, AlertCircle, Save } from "lucide-react"; import { isLinux } from "../lib/platform"; // 分类仅用于控制少量交互(如官方禁用 API Key),不显示介绍组件 +type TemplateValueMap = Record; + +type TemplatePath = Array; + +const collectTemplatePaths = ( + source: unknown, + templateKeys: string[], + currentPath: TemplatePath = [], + acc: TemplatePath[] = [], +): TemplatePath[] => { + if (typeof source === "string") { + const hasPlaceholder = templateKeys.some((key) => + source.includes(`\${${key}}`), + ); + if (hasPlaceholder) { + acc.push([...currentPath]); + } + return acc; + } + + if (Array.isArray(source)) { + source.forEach((item, index) => + collectTemplatePaths(item, templateKeys, [...currentPath, index], acc), + ); + return acc; + } + + if (source && typeof source === "object") { + Object.entries(source).forEach(([key, value]) => + collectTemplatePaths(value, templateKeys, [...currentPath, key], acc), + ); + } + + return acc; +}; + +const getValueAtPath = (source: any, path: TemplatePath) => { + return path.reduce((acc, key) => { + if (acc === undefined || acc === null) { + return undefined; + } + return acc[key as keyof typeof acc]; + }, source); +}; + +const setValueAtPath = ( + target: any, + path: TemplatePath, + value: unknown, +): any => { + if (path.length === 0) { + return value; + } + + let current = target; + + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + const nextKey = path[i + 1]; + const isNextIndex = typeof nextKey === "number"; + + if (current[key as keyof typeof current] === undefined) { + current[key as keyof typeof current] = isNextIndex ? [] : {}; + } else { + const currentValue = current[key as keyof typeof current]; + if (isNextIndex && !Array.isArray(currentValue)) { + current[key as keyof typeof current] = []; + } else if ( + !isNextIndex && + (typeof currentValue !== "object" || currentValue === null) + ) { + current[key as keyof typeof current] = {}; + } + } + + current = current[key as keyof typeof current]; + } + + const finalKey = path[path.length - 1]; + current[finalKey as keyof typeof current] = value; + return target; +}; + +const applyTemplateValuesToConfigString = ( + presetConfig: any, + currentConfigString: string, + values: TemplateValueMap, +) => { + const replacedConfig = applyTemplateValues(presetConfig, values); + const templateKeys = Object.keys(values); + if (templateKeys.length === 0) { + return JSON.stringify(replacedConfig, null, 2); + } + + const placeholderPaths = collectTemplatePaths(presetConfig, templateKeys); + + try { + const parsedConfig = currentConfigString.trim() + ? JSON.parse(currentConfigString) + : {}; + let targetConfig: any; + if (Array.isArray(parsedConfig)) { + targetConfig = [...parsedConfig]; + } else if (parsedConfig && typeof parsedConfig === "object") { + targetConfig = JSON.parse(JSON.stringify(parsedConfig)); + } else { + targetConfig = {}; + } + + if (placeholderPaths.length === 0) { + return JSON.stringify(targetConfig, null, 2); + } + + let mutatedConfig = targetConfig; + + for (const path of placeholderPaths) { + const nextValue = getValueAtPath(replacedConfig, path); + if (path.length === 0) { + mutatedConfig = nextValue; + } else { + setValueAtPath(mutatedConfig, path, nextValue); + } + } + + return JSON.stringify(mutatedConfig, null, 2); + } catch { + return JSON.stringify(replacedConfig, null, 2); + } +}; + const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet"; const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet"; const DEFAULT_COMMON_CONFIG_SNIPPET = `{ @@ -71,6 +203,9 @@ const ProviderForm: React.FC = ({ const [claudeModel, setClaudeModel] = useState(""); const [claudeSmallFastModel, setClaudeSmallFastModel] = useState(""); const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态 + // 模板变量状态 + const [templateValues, setTemplateValues] = + useState>({}); // Codex 特有的状态 const [codexAuth, setCodexAuthState] = useState(""); @@ -157,6 +292,7 @@ const ProviderForm: React.FC = ({ }); const [codexCommonConfigError, setCodexCommonConfigError] = useState(""); const isUpdatingFromCodexCommonConfig = useRef(false); + // -1 表示自定义,null 表示未选择,>= 0 表示预设索引 const [selectedPreset, setSelectedPreset] = useState( showPresets ? -1 : null @@ -377,6 +513,22 @@ const ProviderForm: React.FC = ({ setError(currentSettingsError); return; } + + if (selectedTemplatePreset && templateValueEntries.length > 0) { + for (const [key, config] of templateValueEntries) { + const entry = templateValues[key]; + const resolvedValue = ( + entry?.editorValue ?? + entry?.defaultValue ?? + config.defaultValue ?? + "" + ).trim(); + if (!resolvedValue) { + setError(`请填写 ${config.label}`); + return; + } + } + } // Claude: 原有逻辑 if (!formData.settingsConfig.trim()) { setError("请填写配置内容"); @@ -529,7 +681,30 @@ const ProviderForm: React.FC = ({ }; const applyPreset = (preset: (typeof providerPresets)[0], index: number) => { - const configString = JSON.stringify(preset.settingsConfig, null, 2); + let appliedSettingsConfig = preset.settingsConfig; + let initialTemplateValues: TemplateValueMap = {}; + + if (preset.templateValues) { + initialTemplateValues = Object.fromEntries( + Object.entries(preset.templateValues).map(([key, config]) => [ + key, + { + ...config, + editorValue: config.editorValue + ? config.editorValue + : config.defaultValue ?? "", + }, + ]) + ); + appliedSettingsConfig = applyTemplateValues( + preset.settingsConfig, + initialTemplateValues + ); + } + + setTemplateValues(initialTemplateValues); + + const configString = JSON.stringify(appliedSettingsConfig, null, 2); setFormData({ name: preset.name, @@ -554,8 +729,8 @@ const ProviderForm: React.FC = ({ setCommonConfigError(""); // 如果预设包含模型配置,初始化模型输入框 - if (preset.settingsConfig && typeof preset.settingsConfig === "object") { - const config = preset.settingsConfig as { env?: Record }; + if (appliedSettingsConfig && typeof appliedSettingsConfig === "object") { + const config = appliedSettingsConfig as { env?: Record }; if (config.env) { setClaudeModel(config.env.ANTHROPIC_MODEL || ""); setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || ""); @@ -577,6 +752,7 @@ const ProviderForm: React.FC = ({ // 处理点击自定义按钮 const handleCustomClick = () => { setSelectedPreset(-1); + setTemplateValues({}); // 设置自定义模板 const customTemplate = { @@ -803,6 +979,21 @@ const ProviderForm: React.FC = ({ selectedPreset !== null || (!showPresets && hasApiKeyField(formData.settingsConfig)); + const selectedTemplatePreset = + !isCodex && + selectedPreset !== null && + selectedPreset >= 0 && + selectedPreset < providerPresets.length + ? providerPresets[selectedPreset] + : null; + + const templateValueEntries: Array<[string, TemplateValueConfig]> = + selectedTemplatePreset?.templateValues + ? (Object.entries( + selectedTemplatePreset.templateValues + ) as Array<[string, TemplateValueConfig]>) + : []; + // 判断当前选中的预设是否是官方 const isOfficialPreset = (selectedPreset !== null && @@ -1133,6 +1324,74 @@ const ProviderForm: React.FC = ({ )} + {!isCodex && selectedTemplatePreset && templateValueEntries.length > 0 && ( +
+

+ 参数配置 - {selectedTemplatePreset.name.trim()} * +

+
+ {templateValueEntries.map(([key, config]) => ( +
+ + { + const newValue = e.target.value; + setTemplateValues((prev) => { + const prevEntry = prev[key]; + const nextEntry: TemplateValueConfig = { + ...config, + ...(prevEntry ?? {}), + editorValue: newValue, + }; + const nextValues: TemplateValueMap = { + ...prev, + [key]: nextEntry, + }; + + if (selectedTemplatePreset) { + try { + const configString = applyTemplateValuesToConfigString( + selectedTemplatePreset.settingsConfig, + formData.settingsConfig, + nextValues + ); + setFormData((prevForm) => ({ + ...prevForm, + settingsConfig: configString, + })); + setSettingsConfigError( + validateSettingsConfig(configString) + ); + } catch (err) { + console.error("更新模板值失败:", err); + } + } + + return nextValues; + }); + }} + aria-label={config.label} + autoComplete="off" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ ))} +
+
+ )} + {/* 基础 URL 输入框 - 仅在自定义模式下显示 */} {!isCodex && showBaseUrlInput && (
diff --git a/src/config/providerPresets.ts b/src/config/providerPresets.ts index 8af8099..fdb9d40 100644 --- a/src/config/providerPresets.ts +++ b/src/config/providerPresets.ts @@ -3,6 +3,13 @@ */ import { ProviderCategory } from "../types"; +export interface TemplateValueConfig { + label: string; + placeholder: string; + defaultValue?: string; + editorValue: string; +} + export interface ProviderPreset { name: string; websiteUrl: string; @@ -11,6 +18,8 @@ export interface ProviderPreset { settingsConfig: object; isOfficial?: boolean; // 标识是否为官方预设 category?: ProviderCategory; // 新增:分类 + // 新增:模板变量定义,用于动态替换配置中的值 + templateValues?: Record; // editorValue 存储编辑器中的实时输入值 } export const providerPresets: ProviderPreset[] = [ @@ -101,4 +110,26 @@ export const providerPresets: ProviderPreset[] = [ }, category: "third_party", }, + { + name: "KAT-Coder 官方", + websiteUrl: "https://console.streamlake.ai/wanqing/", + apiKeyUrl: "https://console.streamlake.ai/console/wanqing/api-key", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/claude-code-proxy", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_MODEL: "KAT-Coder", + ANTHROPIC_SMALL_FAST_MODEL: "KAT-Coder", + }, + }, + category: "cn_official", + templateValues: { + ENDPOINT_ID: { + label: "Vanchin Endpoint ID", + placeholder: "ep-xxx-xxx", + defaultValue: "", + editorValue: "", + }, + }, + }, ]; diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 7b2d3f1..04a8ce7 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -1,5 +1,7 @@ // 供应商配置处理工具函数 +import type { TemplateValueConfig } from "../config/providerPresets"; + const isPlainObject = (value: unknown): value is Record => { return Object.prototype.toString.call(value) === "[object Object]"; }; @@ -173,6 +175,51 @@ export const getApiKeyFromConfig = (jsonString: string): string => { } }; +// 模板变量替换 +export const applyTemplateValues = ( + config: any, + templateValues: Record | undefined +): any => { + const resolvedValues = Object.fromEntries( + Object.entries(templateValues ?? {}).map(([key, value]) => { + const resolvedValue = + value.editorValue !== undefined + ? value.editorValue + : value.defaultValue ?? ""; + return [key, resolvedValue]; + }) + ); + + const replaceInString = (str: string): string => { + return Object.entries(resolvedValues).reduce((acc, [key, value]) => { + const placeholder = `\${${key}}`; + if (!acc.includes(placeholder)) { + return acc; + } + return acc.split(placeholder).join(value ?? ""); + }, str); + }; + + const traverse = (obj: any): any => { + if (typeof obj === "string") { + return replaceInString(obj); + } + if (Array.isArray(obj)) { + return obj.map(traverse); + } + if (obj && typeof obj === "object") { + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = traverse(value); + } + return result; + } + return obj; + }; + + return traverse(config); +}; + // 判断配置中是否存在 API Key 字段 export const hasApiKeyField = (jsonString: string): boolean => { try {