feat: implement template variables input functionality

- Create useTemplateValues hook to manage template variable state
- Support dynamic placeholder replacement in provider configs
- Add template parameter input UI in provider form
- Validate required template values before submission
- Auto-update config when template values change
This commit is contained in:
Jason
2025-10-16 20:25:39 +08:00
parent e4f85f4f65
commit 74afca7b58
3 changed files with 356 additions and 0 deletions

View File

@@ -37,6 +37,7 @@ import {
useApiKeyLink, useApiKeyLink,
useCustomEndpoints, useCustomEndpoints,
useKimiModelSelector, useKimiModelSelector,
useTemplateValues,
} from "./hooks"; } from "./hooks";
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2); 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) => { 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; let settingsConfig: string;
// Codex: 组合 auth 和 config // Codex: 组合 auth 和 config
@@ -510,6 +540,43 @@ export function ProviderForm({
</div> </div>
)} )}
{/* 模板变量输入(仅 Claude 且有模板变量时显示) */}
{appType === "claude" && templateValueEntries.length > 0 && (
<div className="space-y-3">
<FormLabel>
{t("providerForm.parameterConfig", {
name: templatePreset?.name || "",
defaultValue: `${templatePreset?.name || ""} 参数配置`,
})}
</FormLabel>
<div className="space-y-4">
{templateValueEntries.map(([key, config]) => (
<div key={key} className="space-y-2">
<FormLabel htmlFor={`template-${key}`}>
{config.label}
</FormLabel>
<Input
id={`template-${key}`}
type="text"
required
value={
templateValues[key]?.editorValue ??
config.editorValue ??
config.defaultValue ??
""
}
onChange={(e) =>
handleTemplateValueChange(key, e.target.value)
}
placeholder={config.placeholder || config.label}
autoComplete="off"
/>
</div>
))}
</div>
</div>
)}
{/* Base URL 输入框(仅 Claude 第三方/自定义显示) */} {/* Base URL 输入框(仅 Claude 第三方/自定义显示) */}
{appType === "claude" && shouldShowSpeedTest && ( {appType === "claude" && shouldShowSpeedTest && (
<div className="space-y-2"> <div className="space-y-2">

View File

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

View File

@@ -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<string | number>;
type TemplateValueMap = Record<string, TemplateValueConfig>;
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<any>((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<TemplateValueMap>({});
// 获取当前选中的预设
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,
};
}