diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 5d7ffc9..53d7929 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -37,6 +37,7 @@ import { useApiKeyLink, useCustomEndpoints, useKimiModelSelector, + useTemplateValues, } from "./hooks"; const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2); @@ -228,7 +229,36 @@ export function ProviderForm({ : "", }); + // 使用模板变量 hook (仅 Claude 模式) + const { + templateValues, + templateValueEntries, + selectedPreset: templatePreset, + handleTemplateValueChange, + validateTemplateValues, + } = useTemplateValues({ + selectedPresetId: appType === "claude" ? selectedPresetId : null, + presetEntries: appType === "claude" ? presetEntries : [], + settingsConfig: form.watch("settingsConfig"), + onConfigChange: (config) => form.setValue("settingsConfig", config), + }); + const handleSubmit = (values: ProviderFormData) => { + // 验证模板变量(仅 Claude 模式) + if (appType === "claude" && templateValueEntries.length > 0) { + const validation = validateTemplateValues(); + if (!validation.isValid && validation.missingField) { + form.setError("settingsConfig", { + type: "manual", + message: t("providerForm.fillParameter", { + label: validation.missingField.label, + defaultValue: `请填写 ${validation.missingField.label}`, + }), + }); + return; + } + } + let settingsConfig: string; // Codex: 组合 auth 和 config @@ -510,6 +540,43 @@ export function ProviderForm({ )} + {/* 模板变量输入(仅 Claude 且有模板变量时显示) */} + {appType === "claude" && templateValueEntries.length > 0 && ( +
+ + {t("providerForm.parameterConfig", { + name: templatePreset?.name || "", + defaultValue: `${templatePreset?.name || ""} 参数配置`, + })} + +
+ {templateValueEntries.map(([key, config]) => ( +
+ + {config.label} + + + handleTemplateValueChange(key, e.target.value) + } + placeholder={config.placeholder || config.label} + autoComplete="off" + /> +
+ ))} +
+
+ )} + {/* Base URL 输入框(仅 Claude 第三方/自定义显示) */} {appType === "claude" && shouldShowSpeedTest && (
diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index 9b51276..5b3579b 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -6,3 +6,4 @@ export { useCodexConfigState } from "./useCodexConfigState"; export { useApiKeyLink } from "./useApiKeyLink"; export { useCustomEndpoints } from "./useCustomEndpoints"; export { useKimiModelSelector } from "./useKimiModelSelector"; +export { useTemplateValues } from "./useTemplateValues"; diff --git a/src/components/providers/forms/hooks/useTemplateValues.ts b/src/components/providers/forms/hooks/useTemplateValues.ts new file mode 100644 index 0000000..22b39cc --- /dev/null +++ b/src/components/providers/forms/hooks/useTemplateValues.ts @@ -0,0 +1,288 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import type { ProviderPreset, TemplateValueConfig } from "@/config/providerPresets"; +import type { CodexProviderPreset } from "@/config/codexProviderPresets"; +import { applyTemplateValues } from "@/utils/providerConfigUtils"; + +type TemplatePath = Array; +type TemplateValueMap = Record; + +interface PresetEntry { + id: string; + preset: ProviderPreset | CodexProviderPreset; +} + +interface UseTemplateValuesProps { + selectedPresetId: string | null; + presetEntries: PresetEntry[]; + settingsConfig: string; + onConfigChange: (config: string) => void; +} + +/** + * 收集配置中包含模板占位符的路径 + */ +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); + } +}; + +/** + * 管理模板变量的状态和逻辑 + */ +export function useTemplateValues({ + selectedPresetId, + presetEntries, + settingsConfig, + onConfigChange, +}: UseTemplateValuesProps) { + const [templateValues, setTemplateValues] = useState({}); + + // 获取当前选中的预设 + const selectedPreset = useMemo(() => { + if (!selectedPresetId || selectedPresetId === "custom") { + return null; + } + const entry = presetEntries.find((item) => item.id === selectedPresetId); + // 只处理 ProviderPreset (Claude 预设) + if (entry && "settingsConfig" in entry.preset) { + return entry.preset as ProviderPreset; + } + return null; + }, [selectedPresetId, presetEntries]); + + // 获取模板变量条目 + const templateValueEntries = useMemo(() => { + if (!selectedPreset?.templateValues) { + return []; + } + return Object.entries(selectedPreset.templateValues) as Array< + [string, TemplateValueConfig] + >; + }, [selectedPreset]); + + // 当选择预设时,初始化模板值 + useEffect(() => { + if (selectedPreset?.templateValues) { + const initialValues = Object.fromEntries( + Object.entries(selectedPreset.templateValues).map(([key, config]) => [ + key, + { + ...config, + editorValue: config.editorValue || config.defaultValue || "", + }, + ]), + ); + setTemplateValues(initialValues); + } else { + setTemplateValues({}); + } + }, [selectedPreset]); + + // 处理模板值变化 + const handleTemplateValueChange = useCallback( + (key: string, value: string) => { + if (!selectedPreset?.templateValues) { + return; + } + + const config = selectedPreset.templateValues[key]; + if (!config) { + return; + } + + setTemplateValues((prev) => { + const prevEntry = prev[key]; + const nextEntry: TemplateValueConfig = { + ...config, + ...(prevEntry ?? {}), + editorValue: value, + }; + const nextValues: TemplateValueMap = { + ...prev, + [key]: nextEntry, + }; + + // 应用模板值到配置 + try { + const configString = applyTemplateValuesToConfigString( + selectedPreset.settingsConfig, + settingsConfig, + nextValues, + ); + onConfigChange(configString); + } catch (err) { + console.error("更新模板值失败:", err); + } + + return nextValues; + }); + }, + [selectedPreset, settingsConfig, onConfigChange], + ); + + // 验证所有模板值是否已填写 + const validateTemplateValues = useCallback((): { + isValid: boolean; + missingField?: { key: string; label: string }; + } => { + if (templateValueEntries.length === 0) { + return { isValid: true }; + } + + for (const [key, config] of templateValueEntries) { + const entry = templateValues[key]; + const resolvedValue = ( + entry?.editorValue ?? + entry?.defaultValue ?? + config.defaultValue ?? + "" + ).trim(); + if (!resolvedValue) { + return { + isValid: false, + missingField: { key, label: config.label }, + }; + } + } + + return { isValid: true }; + }, [templateValueEntries, templateValues]); + + return { + templateValues, + templateValueEntries, + selectedPreset, + handleTemplateValueChange, + validateTemplateValues, + }; +}