mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-01-30 06:12:06 +00:00
change doc
This commit is contained in:
@@ -18,8 +18,14 @@ import {
|
||||
findPresetFile,
|
||||
isPresetInstalled,
|
||||
ManifestFile,
|
||||
PresetFile
|
||||
PresetFile,
|
||||
RequiredInput,
|
||||
UserInputValues,
|
||||
applyConfigMappings,
|
||||
replaceTemplateVariables,
|
||||
setValueByPath,
|
||||
} from '@CCR/shared';
|
||||
import { collectUserInputs } from '../prompt/schema-input';
|
||||
|
||||
// 重新导出 loadPreset
|
||||
export { loadPresetShared as loadPreset };
|
||||
@@ -34,67 +40,38 @@ const BOLDCYAN = "\x1B[1m\x1B[36m";
|
||||
const DIM = "\x1B[2m";
|
||||
|
||||
/**
|
||||
* 收集缺失的敏感信息
|
||||
* 应用用户输入到配置(新版schema)
|
||||
*/
|
||||
async function collectSensitiveInputs(
|
||||
preset: PresetFile
|
||||
): Promise<Record<string, string>> {
|
||||
const inputs: Record<string, string> = {};
|
||||
function applyUserInputs(
|
||||
preset: PresetFile,
|
||||
values: UserInputValues
|
||||
): PresetConfigSection {
|
||||
let config = { ...preset.config };
|
||||
|
||||
if (!preset.requiredInputs || preset.requiredInputs.length === 0) {
|
||||
return inputs;
|
||||
// 1. 先应用 template(如果存在)
|
||||
if (preset.template) {
|
||||
config = replaceTemplateVariables(preset.template, values) as any;
|
||||
}
|
||||
|
||||
console.log(`\n${BOLDYELLOW}This preset requires additional information:${RESET}\n`);
|
||||
|
||||
for (const inputField of preset.requiredInputs) {
|
||||
let value: string;
|
||||
|
||||
// 尝试从环境变量获取
|
||||
const envVarName = inputField.placeholder;
|
||||
if (envVarName && process.env[envVarName]) {
|
||||
const useEnv = await confirm({
|
||||
message: `Found ${envVarName} in environment. Use it?`,
|
||||
default: true,
|
||||
});
|
||||
|
||||
if (useEnv) {
|
||||
value = process.env[envVarName]!;
|
||||
inputs[inputField.field] = value;
|
||||
console.log(`${GREEN}✓${RESET} Using ${envVarName} from environment\n`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 提示用户输入
|
||||
value = await password({
|
||||
message: inputField.prompt || `Enter ${inputField.field}:`,
|
||||
mask: '*',
|
||||
});
|
||||
|
||||
if (!value || value.trim() === '') {
|
||||
console.error(`${YELLOW}Error:${RESET} ${inputField.field} is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 验证输入
|
||||
if (inputField.validator) {
|
||||
const regex = typeof inputField.validator === 'string'
|
||||
? new RegExp(inputField.validator)
|
||||
: inputField.validator;
|
||||
|
||||
if (!regex.test(value)) {
|
||||
console.error(`${YELLOW}Error:${RESET} Invalid format for ${inputField.field}`);
|
||||
console.error(` Expected: ${inputField.validator}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
inputs[inputField.field] = value;
|
||||
console.log('');
|
||||
// 2. 再应用 configMappings(如果存在)
|
||||
if (preset.configMappings && preset.configMappings.length > 0) {
|
||||
config = applyConfigMappings(preset.configMappings, values, config);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
// 3. 兼容旧版:直接将 values 应用到 config
|
||||
// 检查是否有任何值没有通过 mappings 应用
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
// 如果这个值已经在 template 或 mappings 中处理过,跳过
|
||||
// 这里简化处理:直接应用所有值
|
||||
// 在实际使用中,template 和 mappings 应该覆盖所有需要设置的字段
|
||||
|
||||
// 尝试智能判断:如果 key 包含 '.' 或 '[',说明是路径
|
||||
if (key.includes('.') || key.includes('[')) {
|
||||
setValueByPath(config, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +113,7 @@ export async function applyPresetCli(
|
||||
// 检查是否已经配置了敏感信息(例如api_key)
|
||||
const hasSecrets = existingManifest.Providers?.some((p: any) => p.api_key && p.api_key !== '');
|
||||
if (hasSecrets) {
|
||||
console.log(`\n${GREEN}✓${RESET} Preset already configured with secrets`);
|
||||
console.log(`\n${GREEN}✓${RESET} Preset already configured`);
|
||||
console.log(`${DIM}You can use this preset with: ccr ${presetName}${RESET}\n`);
|
||||
return;
|
||||
}
|
||||
@@ -144,31 +121,34 @@ export async function applyPresetCli(
|
||||
// manifest不存在,继续配置流程
|
||||
}
|
||||
|
||||
// 收集敏感信息
|
||||
const sensitiveInputs = await collectSensitiveInputs(preset);
|
||||
// 收集用户输入
|
||||
let userInputs: UserInputValues = {};
|
||||
|
||||
// 使用 schema 系统
|
||||
if (preset.schema && preset.schema.length > 0) {
|
||||
userInputs = await collectUserInputs(preset.schema, preset.config);
|
||||
}
|
||||
|
||||
// 应用用户输入到配置
|
||||
const finalConfig = applyUserInputs(preset, userInputs);
|
||||
|
||||
// 读取现有的manifest并更新
|
||||
const manifest: ManifestFile = {
|
||||
...(preset.metadata || {}),
|
||||
...preset.config,
|
||||
...finalConfig,
|
||||
};
|
||||
|
||||
// 将secrets信息应用到manifest中
|
||||
for (const [fieldPath, value] of Object.entries(sensitiveInputs)) {
|
||||
const keys = fieldPath.split(/[.\[\]]+/).filter(k => k !== '');
|
||||
let current = manifest as any;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (!current[key]) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
current[keys[keys.length - 1]] = value;
|
||||
// 保存 schema(如果存在)
|
||||
if (preset.schema) {
|
||||
manifest.schema = preset.schema;
|
||||
}
|
||||
|
||||
if (preset.requiredInputs) {
|
||||
manifest.requiredInputs = preset.requiredInputs;
|
||||
// 保存其他配置
|
||||
if (preset.template) {
|
||||
manifest.template = preset.template;
|
||||
}
|
||||
if (preset.configMappings) {
|
||||
manifest.configMappings = preset.configMappings;
|
||||
}
|
||||
|
||||
// 保存到解压目录的manifest.json
|
||||
@@ -177,7 +157,7 @@ export async function applyPresetCli(
|
||||
// 显示摘要
|
||||
console.log(`\n${BOLDGREEN}✓ Preset configured successfully!${RESET}\n`);
|
||||
console.log(`${BOLDCYAN}Preset directory:${RESET} ${presetDir}`);
|
||||
console.log(`${BOLDCYAN}Secrets configured:${RESET} ${Object.keys(sensitiveInputs).length}`);
|
||||
console.log(`${BOLDCYAN}Inputs configured:${RESET} ${Object.keys(userInputs).length}`);
|
||||
|
||||
if (preset.metadata?.description) {
|
||||
console.log(`\n${BOLDCYAN}Description:${RESET} ${preset.metadata.description}`);
|
||||
@@ -193,7 +173,7 @@ export async function applyPresetCli(
|
||||
}
|
||||
|
||||
console.log(`\n${GREEN}Use this preset:${RESET} ccr ${presetName} "your prompt"`);
|
||||
console.log(`${DIM}Note: Secrets are stored in the manifest file${RESET}\n`);
|
||||
console.log(`${DIM}Note: Configuration is stored in the manifest file${RESET}\n`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`\n${YELLOW}Error applying preset:${RESET} ${error.message}`);
|
||||
|
||||
230
packages/cli/src/utils/prompt/schema-input.ts
Normal file
230
packages/cli/src/utils/prompt/schema-input.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 动态配置 CLI 交互处理器
|
||||
* 处理各种输入类型的用户交互
|
||||
*/
|
||||
|
||||
import {
|
||||
RequiredInput,
|
||||
InputType,
|
||||
UserInputValues,
|
||||
PresetConfigSection,
|
||||
shouldShowField,
|
||||
resolveOptions,
|
||||
validateInput,
|
||||
getDefaultValue,
|
||||
sortFieldsByDependencies,
|
||||
getAffectedFields,
|
||||
} from '@musistudio/claude-code-router-shared';
|
||||
import { input, confirm, select, password } from '@inquirer/prompts';
|
||||
|
||||
// ANSI 颜色代码
|
||||
export const COLORS = {
|
||||
RESET: "\x1B[0m",
|
||||
GREEN: "\x1B[32m",
|
||||
YELLOW: "\x1B[33m",
|
||||
BOLDYELLOW: "\x1B[1m\x1B[33m",
|
||||
BOLDCYAN: "\x1B[1m\x1B[36m",
|
||||
DIM: "\x1B[2m",
|
||||
BOLDGREEN: "\x1B[1m\x1B[32m",
|
||||
};
|
||||
|
||||
/**
|
||||
* 收集用户输入(支持动态配置)
|
||||
*/
|
||||
export async function collectUserInputs(
|
||||
schema: RequiredInput[],
|
||||
presetConfig: PresetConfigSection,
|
||||
existingValues?: UserInputValues
|
||||
): Promise<UserInputValues> {
|
||||
// 按依赖关系排序
|
||||
const sortedFields = sortFieldsByDependencies(schema);
|
||||
|
||||
// 初始化值
|
||||
const values: UserInputValues = { ...existingValues };
|
||||
|
||||
// 收集所有输入
|
||||
for (const field of sortedFields) {
|
||||
// 检查是否应该显示此字段
|
||||
if (!shouldShowField(field, values)) {
|
||||
// 跳过,并清除该字段的值(如果之前存在)
|
||||
delete values[field.id];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果已有值且不是初始收集,跳过
|
||||
if (existingValues && field.id in existingValues) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取输入值
|
||||
const value = await promptField(field, presetConfig, values);
|
||||
|
||||
// 验证
|
||||
const validation = validateInput(field, value);
|
||||
if (!validation.valid) {
|
||||
console.error(`${COLORS.YELLOW}Error:${COLORS.RESET} ${validation.error}`);
|
||||
// 对于必填字段,抛出错误
|
||||
if (field.required !== false) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
}
|
||||
|
||||
values[field.id] = value;
|
||||
console.log('');
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新收集受影响的字段(当某个字段值变化时)
|
||||
*/
|
||||
export async function recollectAffectedFields(
|
||||
changedFieldId: string,
|
||||
schema: RequiredInput[],
|
||||
presetConfig: PresetConfigSection,
|
||||
currentValues: UserInputValues
|
||||
): Promise<UserInputValues> {
|
||||
const affectedFields = getAffectedFields(changedFieldId, schema);
|
||||
const sortedFields = sortFieldsByDependencies(schema);
|
||||
|
||||
const values = { ...currentValues };
|
||||
|
||||
// 对受影响的字段重新收集输入
|
||||
for (const fieldId of affectedFields) {
|
||||
const field = sortedFields.find(f => f.id === fieldId);
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否应该显示
|
||||
if (!shouldShowField(field, values)) {
|
||||
delete values[field.id];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 重新收集输入
|
||||
const value = await promptField(field, presetConfig, values);
|
||||
values[field.id] = value;
|
||||
|
||||
// 级联更新:如果这个字段的变化又影响了其他字段
|
||||
const newAffected = getAffectedFields(field.id, schema);
|
||||
for (const newAffectedId of newAffected) {
|
||||
if (!affectedFields.has(newAffectedId)) {
|
||||
affectedFields.add(newAffectedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提示单个字段
|
||||
*/
|
||||
async function promptField(
|
||||
field: RequiredInput,
|
||||
presetConfig: PresetConfigSection,
|
||||
currentValues: UserInputValues
|
||||
): Promise<any> {
|
||||
const label = field.label || field.id;
|
||||
const message = field.prompt || `${label}:`;
|
||||
|
||||
switch (field.type) {
|
||||
case InputType.PASSWORD:
|
||||
return await password({
|
||||
message,
|
||||
mask: '*',
|
||||
});
|
||||
|
||||
case InputType.INPUT:
|
||||
return await input({
|
||||
message,
|
||||
default: field.defaultValue,
|
||||
});
|
||||
|
||||
case InputType.NUMBER:
|
||||
const numStr = await input({
|
||||
message,
|
||||
default: String(field.defaultValue ?? 0),
|
||||
});
|
||||
return Number(numStr);
|
||||
|
||||
case InputType.CONFIRM:
|
||||
return await confirm({
|
||||
message,
|
||||
default: field.defaultValue ?? false,
|
||||
});
|
||||
|
||||
case InputType.SELECT: {
|
||||
const options = resolveOptions(field, presetConfig, currentValues);
|
||||
if (options.length === 0) {
|
||||
console.warn(`${COLORS.YELLOW}Warning:${COLORS.RESET} No options available for ${label}`);
|
||||
return field.defaultValue;
|
||||
}
|
||||
|
||||
return await select({
|
||||
message,
|
||||
choices: options.map(opt => ({
|
||||
name: opt.label,
|
||||
value: opt.value,
|
||||
description: opt.description,
|
||||
disabled: opt.disabled,
|
||||
})),
|
||||
default: field.defaultValue,
|
||||
});
|
||||
}
|
||||
|
||||
case InputType.MULTISELECT: {
|
||||
const options = resolveOptions(field, presetConfig, currentValues);
|
||||
if (options.length === 0) {
|
||||
console.warn(`${COLORS.YELLOW}Warning:${COLORS.RESET} No options available for ${label}`);
|
||||
return field.defaultValue ?? [];
|
||||
}
|
||||
|
||||
// @inquirer/prompts 没有多选,使用 checkbox
|
||||
const { checkbox } = await import('@inquirer/prompts');
|
||||
return await checkbox({
|
||||
message,
|
||||
choices: options.map(opt => ({
|
||||
name: opt.label,
|
||||
value: opt.value,
|
||||
checked: Array.isArray(field.defaultValue) && field.defaultValue.includes(opt.value),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
case InputType.EDITOR: {
|
||||
const { editor } = await import('@inquirer/prompts');
|
||||
return await editor({
|
||||
message,
|
||||
default: field.defaultValue,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
// 默认使用 input
|
||||
return await input({
|
||||
message,
|
||||
default: field.defaultValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集敏感信息(兼容旧版)
|
||||
*/
|
||||
export async function collectSensitiveInputs(
|
||||
schema: RequiredInput[],
|
||||
presetConfig: PresetConfigSection,
|
||||
existingValues?: UserInputValues
|
||||
): Promise<UserInputValues> {
|
||||
console.log(`\n${COLORS.BOLDYELLOW}This preset requires additional information:${COLORS.RESET}\n`);
|
||||
|
||||
const values = await collectUserInputs(schema, presetConfig, existingValues);
|
||||
|
||||
// 显示摘要
|
||||
console.log(`${COLORS.GREEN}✓${COLORS.RESET} All required information collected\n`);
|
||||
|
||||
return values;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './preset/merge';
|
||||
export * from './preset/install';
|
||||
export * from './preset/export';
|
||||
export * from './preset/readPreset';
|
||||
export * from './preset/schema';
|
||||
|
||||
530
packages/shared/src/preset/schema.ts
Normal file
530
packages/shared/src/preset/schema.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* 动态配置 Schema 处理器
|
||||
* 负责解析和验证配置 schema,处理条件逻辑和变量替换
|
||||
*/
|
||||
|
||||
import {
|
||||
RequiredInput,
|
||||
InputType,
|
||||
Condition,
|
||||
DynamicOptions,
|
||||
InputOption,
|
||||
ConfigMapping,
|
||||
TemplateConfig,
|
||||
PresetConfigSection,
|
||||
} from './types';
|
||||
|
||||
// 用户输入值集合
|
||||
export interface UserInputValues {
|
||||
[inputId: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析字段路径(支持数组和嵌套)
|
||||
* 例如:Providers[0].name => ['Providers', '0', 'name']
|
||||
*/
|
||||
export function parseFieldPath(path: string): string[] {
|
||||
const regex = /(\w+)|\[(\d+)\]/g;
|
||||
const parts: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(path)) !== null) {
|
||||
parts.push(match[1] || match[2]);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字段路径获取对象中的值
|
||||
*/
|
||||
export function getValueByPath(obj: any, path: string): any {
|
||||
const parts = parseFieldPath(path);
|
||||
let current = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current == null) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字段路径设置对象中的值
|
||||
*/
|
||||
export function setValueByPath(obj: any, path: string, value: any): void {
|
||||
const parts = parseFieldPath(path);
|
||||
const lastKey = parts.pop()!;
|
||||
let current = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!(part in current)) {
|
||||
// 判断是数组还是对象
|
||||
const nextPart = parts[parts.indexOf(part) + 1];
|
||||
if (nextPart && /^\d+$/.test(nextPart)) {
|
||||
current[part] = [];
|
||||
} else {
|
||||
current[part] = {};
|
||||
}
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估条件表达式
|
||||
*/
|
||||
export function evaluateCondition(
|
||||
condition: Condition,
|
||||
values: UserInputValues
|
||||
): boolean {
|
||||
const actualValue = values[condition.field];
|
||||
|
||||
// 处理 exists 操作符
|
||||
if (condition.operator === 'exists') {
|
||||
return actualValue !== undefined && actualValue !== null;
|
||||
}
|
||||
|
||||
// 处理 in 操作符
|
||||
if (condition.operator === 'in') {
|
||||
return Array.isArray(condition.value) && condition.value.includes(actualValue);
|
||||
}
|
||||
|
||||
// 处理 nin 操作符
|
||||
if (condition.operator === 'nin') {
|
||||
return Array.isArray(condition.value) && !condition.value.includes(actualValue);
|
||||
}
|
||||
|
||||
// 处理其他操作符
|
||||
switch (condition.operator) {
|
||||
case 'eq':
|
||||
return actualValue === condition.value;
|
||||
case 'ne':
|
||||
return actualValue !== condition.value;
|
||||
case 'gt':
|
||||
return actualValue > condition.value;
|
||||
case 'lt':
|
||||
return actualValue < condition.value;
|
||||
case 'gte':
|
||||
return actualValue >= condition.value;
|
||||
case 'lte':
|
||||
return actualValue <= condition.value;
|
||||
default:
|
||||
// 默认使用 eq
|
||||
return actualValue === condition.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估多个条件(AND 逻辑)
|
||||
*/
|
||||
export function evaluateConditions(
|
||||
conditions: Condition | Condition[],
|
||||
values: UserInputValues
|
||||
): boolean {
|
||||
if (!conditions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Array.isArray(conditions)) {
|
||||
return evaluateCondition(conditions, values);
|
||||
}
|
||||
|
||||
// 如果是数组,使用 AND 逻辑(所有条件都必须满足)
|
||||
return conditions.every(condition => evaluateCondition(condition, values));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否应该显示
|
||||
*/
|
||||
export function shouldShowField(
|
||||
field: RequiredInput,
|
||||
values: UserInputValues
|
||||
): boolean {
|
||||
if (!field.when) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return evaluateConditions(field.when, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动态选项列表
|
||||
*/
|
||||
export function getDynamicOptions(
|
||||
dynamicOptions: DynamicOptions,
|
||||
presetConfig: PresetConfigSection,
|
||||
values: UserInputValues
|
||||
): InputOption[] {
|
||||
switch (dynamicOptions.type) {
|
||||
case 'static':
|
||||
return dynamicOptions.options || [];
|
||||
|
||||
case 'providers': {
|
||||
// 从预设的 Providers 中提取选项
|
||||
const providers = presetConfig.Providers || [];
|
||||
return providers.map((p: any) => ({
|
||||
label: p.name || p.id || String(p),
|
||||
value: p.name || p.id || String(p),
|
||||
description: p.api_base_url,
|
||||
}));
|
||||
}
|
||||
|
||||
case 'models': {
|
||||
// 从指定 provider 的 models 中提取
|
||||
const providerField = dynamicOptions.providerField;
|
||||
if (!providerField) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 解析 provider 引用(如 {{selectedProvider}})
|
||||
const providerId = String(providerField).replace(/^{{(.+)}}$/, '$1');
|
||||
const selectedProvider = values[providerId];
|
||||
|
||||
if (!selectedProvider || !presetConfig.Providers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 查找对应的 provider
|
||||
const provider = presetConfig.Providers.find(
|
||||
(p: any) => p.name === selectedProvider || p.id === selectedProvider
|
||||
);
|
||||
|
||||
if (!provider || !provider.models) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return provider.models.map((model: string) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
}));
|
||||
}
|
||||
|
||||
case 'custom':
|
||||
// 预留,暂未实现
|
||||
return [];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析选项(支持静态和动态选项)
|
||||
*/
|
||||
export function resolveOptions(
|
||||
field: RequiredInput,
|
||||
presetConfig: PresetConfigSection,
|
||||
values: UserInputValues
|
||||
): InputOption[] {
|
||||
if (!field.options) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 判断是静态选项还是动态选项
|
||||
const options = field.options as any;
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
// 静态选项数组
|
||||
return options as InputOption[];
|
||||
}
|
||||
|
||||
if (options.type) {
|
||||
// 动态选项
|
||||
return getDynamicOptions(options, presetConfig, values);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板变量替换
|
||||
* 支持 {{variable}} 语法
|
||||
*/
|
||||
export function replaceTemplateVariables(
|
||||
template: any,
|
||||
values: UserInputValues
|
||||
): any {
|
||||
if (template === null || template === undefined) {
|
||||
return template;
|
||||
}
|
||||
|
||||
// 处理字符串
|
||||
if (typeof template === 'string') {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||
return values[key] !== undefined ? String(values[key]) : '';
|
||||
});
|
||||
}
|
||||
|
||||
// 处理数组
|
||||
if (Array.isArray(template)) {
|
||||
return template.map(item => replaceTemplateVariables(item, values));
|
||||
}
|
||||
|
||||
// 处理对象
|
||||
if (typeof template === 'object') {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
result[key] = replaceTemplateVariables(value, values);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 其他类型直接返回
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用配置映射
|
||||
*/
|
||||
export function applyConfigMappings(
|
||||
mappings: ConfigMapping[],
|
||||
values: UserInputValues,
|
||||
config: PresetConfigSection
|
||||
): PresetConfigSection {
|
||||
const result = { ...config };
|
||||
|
||||
for (const mapping of mappings) {
|
||||
// 检查条件
|
||||
if (mapping.when && !evaluateConditions(mapping.when, values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析值
|
||||
let value: any;
|
||||
if (typeof mapping.value === 'string' && mapping.value.startsWith('{{')) {
|
||||
// 变量引用
|
||||
const varName = mapping.value.replace(/^{{(.+)}}$/, '$1');
|
||||
value = values[varName];
|
||||
} else {
|
||||
// 固定值
|
||||
value = mapping.value;
|
||||
}
|
||||
|
||||
// 应用到目标路径
|
||||
setValueByPath(result, mapping.target, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户输入
|
||||
*/
|
||||
export function validateInput(
|
||||
field: RequiredInput,
|
||||
value: any
|
||||
): { valid: boolean; error?: string } {
|
||||
// 检查必填
|
||||
if (field.required !== false && (value === undefined || value === null || value === '')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${field.label || field.id} is required`,
|
||||
};
|
||||
}
|
||||
|
||||
// 如果值为空且非必填,跳过验证
|
||||
if (!value && field.required === false) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// 类型检查
|
||||
switch (field.type) {
|
||||
case InputType.NUMBER:
|
||||
if (isNaN(Number(value))) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${field.label || field.id} must be a number`,
|
||||
};
|
||||
}
|
||||
const numValue = Number(value);
|
||||
if (field.min !== undefined && numValue < field.min) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${field.label || field.id} must be at least ${field.min}`,
|
||||
};
|
||||
}
|
||||
if (field.max !== undefined && numValue > field.max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${field.label || field.id} must be at most ${field.max}`,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case InputType.SELECT:
|
||||
case InputType.MULTISELECT:
|
||||
// 检查值是否在选项中
|
||||
// 这里暂时跳过,因为需要动态获取选项
|
||||
break;
|
||||
}
|
||||
|
||||
// 自定义验证器
|
||||
if (field.validator) {
|
||||
if (field.validator instanceof RegExp) {
|
||||
if (!field.validator.test(String(value))) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${field.label || field.id} format is invalid`,
|
||||
};
|
||||
}
|
||||
} else if (typeof field.validator === 'string') {
|
||||
const regex = new RegExp(field.validator);
|
||||
if (!regex.test(String(value))) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${field.label || field.id} format is invalid`,
|
||||
};
|
||||
}
|
||||
} else if (typeof field.validator === 'function') {
|
||||
const result = field.validator(value);
|
||||
if (result === false) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${field.label || field.id} is invalid`,
|
||||
};
|
||||
} else if (typeof result === 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
error: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段的默认值
|
||||
*/
|
||||
export function getDefaultValue(field: RequiredInput): any {
|
||||
if (field.defaultValue !== undefined) {
|
||||
return field.defaultValue;
|
||||
}
|
||||
|
||||
// 根据类型返回默认值
|
||||
switch (field.type) {
|
||||
case InputType.CONFIRM:
|
||||
return false;
|
||||
case InputType.MULTISELECT:
|
||||
return [];
|
||||
case InputType.NUMBER:
|
||||
return 0;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据依赖关系排序字段
|
||||
* 确保被依赖的字段排在前面
|
||||
*/
|
||||
export function sortFieldsByDependencies(
|
||||
fields: RequiredInput[]
|
||||
): RequiredInput[] {
|
||||
const sorted: RequiredInput[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
function visit(field: RequiredInput) {
|
||||
if (visited.has(field.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
visited.add(field.id);
|
||||
|
||||
// 先处理依赖的字段
|
||||
const dependencies = field.dependsOn || [];
|
||||
for (const depId of dependencies) {
|
||||
const depField = fields.find(f => f.id === depId);
|
||||
if (depField) {
|
||||
visit(depField);
|
||||
}
|
||||
}
|
||||
|
||||
// 从 when 条件中提取依赖
|
||||
if (field.when) {
|
||||
const conditions = Array.isArray(field.when) ? field.when : [field.when];
|
||||
for (const cond of conditions) {
|
||||
const depField = fields.find(f => f.id === cond.field);
|
||||
if (depField) {
|
||||
visit(depField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sorted.push(field);
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
visit(field);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建字段依赖图(用于优化更新顺序)
|
||||
*/
|
||||
export function buildDependencyGraph(
|
||||
fields: RequiredInput[]
|
||||
): Map<string, Set<string>> {
|
||||
const graph = new Map<string, Set<string>>();
|
||||
|
||||
for (const field of fields) {
|
||||
const deps = new Set<string>();
|
||||
|
||||
// 从 dependsOn 提取依赖
|
||||
if (field.dependsOn) {
|
||||
for (const dep of field.dependsOn) {
|
||||
deps.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
// 从 when 条件提取依赖
|
||||
if (field.when) {
|
||||
const conditions = Array.isArray(field.when) ? field.when : [field.when];
|
||||
for (const cond of conditions) {
|
||||
deps.add(cond.field);
|
||||
}
|
||||
}
|
||||
|
||||
// 从动态选项提取依赖
|
||||
if (field.options) {
|
||||
const options = field.options as any;
|
||||
if (options.type === 'models' && options.providerField) {
|
||||
const providerId = String(options.providerField).replace(/^{{(.+)}}$/, '$1');
|
||||
deps.add(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
graph.set(field.id, deps);
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取受影响字段(当某个字段值变化时,哪些字段需要重新计算)
|
||||
*/
|
||||
export function getAffectedFields(
|
||||
changedFieldId: string,
|
||||
fields: RequiredInput[]
|
||||
): Set<string> {
|
||||
const affected = new Set<string>();
|
||||
const graph = buildDependencyGraph(fields);
|
||||
|
||||
// 找出所有依赖于 changedFieldId 的字段
|
||||
for (const [fieldId, deps] of graph.entries()) {
|
||||
if (deps.has(changedFieldId)) {
|
||||
affected.add(fieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return affected;
|
||||
}
|
||||
@@ -2,13 +2,91 @@
|
||||
* 预设功能的类型定义
|
||||
*/
|
||||
|
||||
// 敏感字段输入要求
|
||||
// 输入类型枚举
|
||||
export enum InputType {
|
||||
PASSWORD = 'password', // 密码输入(隐藏)
|
||||
INPUT = 'input', // 文本输入
|
||||
SELECT = 'select', // 单选
|
||||
MULTISELECT = 'multiselect', // 多选
|
||||
CONFIRM = 'confirm', // 确认框
|
||||
EDITOR = 'editor', // 多行文本编辑器
|
||||
NUMBER = 'number', // 数字输入
|
||||
}
|
||||
|
||||
// 选项定义
|
||||
export interface InputOption {
|
||||
label: string; // 显示文本
|
||||
value: string | number | boolean; // 实际值
|
||||
description?: string; // 选项描述
|
||||
disabled?: boolean; // 是否禁用
|
||||
icon?: string; // 图标
|
||||
}
|
||||
|
||||
// 动态选项源
|
||||
export interface DynamicOptions {
|
||||
type: 'static' | 'providers' | 'models' | 'custom';
|
||||
// static: 使用固定的 options 数组
|
||||
// providers: 从 Providers 配置中动态获取
|
||||
// models: 从指定 provider 的 models 中获取
|
||||
// custom: 自定义函数(暂未实现,预留)
|
||||
|
||||
// 当 type 为 'static' 时使用
|
||||
options?: InputOption[];
|
||||
|
||||
// 当 type 为 'providers' 时使用
|
||||
// 自动从预设的 Providers 中提取 name 和相关配置
|
||||
|
||||
// 当 type 为 'models' 时使用
|
||||
providerField?: string; // 指向 provider 选择器的字段路径(如 "{{selectedProvider}}")
|
||||
|
||||
// 当 type 为 'custom' 时使用(预留)
|
||||
source?: string; // 自定义数据源
|
||||
}
|
||||
|
||||
// 条件表达式
|
||||
export interface Condition {
|
||||
field: string; // 依赖的字段路径
|
||||
operator?: 'eq' | 'ne' | 'in' | 'nin' | 'gt' | 'lt' | 'gte' | 'lte' | 'exists';
|
||||
value?: any; // 比较值
|
||||
// eq: 等于
|
||||
// ne: 不等于
|
||||
// in: 包含于(数组)
|
||||
// nin: 不包含于(数组)
|
||||
// gt: 大于
|
||||
// lt: 小于
|
||||
// gte: 大于等于
|
||||
// lte: 小于等于
|
||||
// exists: 字段存在(不检查值)
|
||||
}
|
||||
|
||||
// 复杂的字段输入配置
|
||||
export interface RequiredInput {
|
||||
field: string; // 字段路径 (如 "Providers[0].api_key")
|
||||
prompt?: string; // 提示信息
|
||||
placeholder?: string; // 占位符环境变量名
|
||||
defaultValue?: string; // 默认值
|
||||
validator?: RegExp | string; // 验证规则
|
||||
id: string; // 唯一标识符(用于变量引用)
|
||||
type?: InputType; // 输入类型,默认为 password
|
||||
label?: string; // 显示标签
|
||||
prompt?: string; // 提示信息/描述
|
||||
placeholder?: string; // 占位符
|
||||
|
||||
// 选项配置(用于 select/multiselect)
|
||||
options?: InputOption[] | DynamicOptions;
|
||||
|
||||
// 条件显示
|
||||
when?: Condition | Condition[]; // 满足条件时才显示此字段(支持 AND/OR 逻辑)
|
||||
|
||||
// 默认值
|
||||
defaultValue?: any;
|
||||
|
||||
// 验证规则
|
||||
required?: boolean; // 是否必填,默认 true
|
||||
validator?: RegExp | string | ((value: any) => boolean | string);
|
||||
|
||||
// UI 配置
|
||||
min?: number; // 最小值(用于 number)
|
||||
max?: number; // 最大值(用于 number)
|
||||
rows?: number; // 行数(用于 editor)
|
||||
|
||||
// 高级配置
|
||||
dependsOn?: string[]; // 显式声明依赖的字段(用于优化更新顺序)
|
||||
}
|
||||
|
||||
// Provider 配置
|
||||
@@ -50,7 +128,7 @@ export interface PresetMetadata {
|
||||
homepage?: string; // 主页
|
||||
repository?: string; // 源码仓库
|
||||
license?: string; // 许可证
|
||||
keywords?: string[]; // 关键词(原tags)
|
||||
keywords?: string[]; // 关键词
|
||||
ccrVersion?: string; // 兼容的 CCR 版本
|
||||
source?: string; // 预设来源 URL
|
||||
sourceType?: 'local' | 'gist' | 'registry';
|
||||
@@ -73,6 +151,25 @@ export interface PresetConfigSection {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 模板配置(用于根据用户输入动态生成配置)
|
||||
export interface TemplateConfig {
|
||||
// 使用 {{variable}} 语法的模板配置
|
||||
// 例如:{ "Providers": [{ "name": "{{providerName}}", "api_key": "{{apiKey}}" }] }
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 配置映射(将用户输入的值映射到配置的具体位置)
|
||||
export interface ConfigMapping {
|
||||
// 字段路径(支持数组语法,如 "Providers[0].api_key")
|
||||
target: string;
|
||||
|
||||
// 值来源(引用用户输入的 id,或使用固定值)
|
||||
value: string | any; // 如果是 string 且以 {{ 开头,则作为变量引用
|
||||
|
||||
// 条件(可选,满足条件时才应用此映射)
|
||||
when?: Condition | Condition[];
|
||||
}
|
||||
|
||||
// 完整的预设文件格式
|
||||
export interface PresetFile {
|
||||
metadata?: PresetMetadata;
|
||||
@@ -82,12 +179,24 @@ export interface PresetFile {
|
||||
// 例如:{ "Providers[0].api_key": "sk-xxx", "APIKEY": "my-secret" }
|
||||
[fieldPath: string]: string;
|
||||
};
|
||||
requiredInputs?: RequiredInput[];
|
||||
|
||||
// === 动态配置系统 ===
|
||||
// 配置输入schema
|
||||
schema?: RequiredInput[];
|
||||
|
||||
// 配置模板(使用变量替换)
|
||||
template?: TemplateConfig;
|
||||
|
||||
// 配置映射(将用户输入映射到配置)
|
||||
configMappings?: ConfigMapping[];
|
||||
}
|
||||
|
||||
// manifest.json 格式(压缩包内的文件)
|
||||
export interface ManifestFile extends PresetMetadata, PresetConfigSection {
|
||||
requiredInputs?: RequiredInput[];
|
||||
// === 动态配置系统 ===
|
||||
schema?: RequiredInput[];
|
||||
template?: TemplateConfig;
|
||||
configMappings?: ConfigMapping[];
|
||||
}
|
||||
|
||||
// 在线预设索引条目
|
||||
|
||||
@@ -16,6 +16,44 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Upload, Link, Trash2, Info, Download, CheckCircle2, AlertCircle, Loader2, ArrowLeft, Store, Search, Package } from "lucide-react";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
import { DynamicConfigForm } from "./preset/DynamicConfigForm";
|
||||
|
||||
// Schema 类型
|
||||
interface InputOption {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DynamicOptions {
|
||||
type: 'static' | 'providers' | 'models' | 'custom';
|
||||
options?: InputOption[];
|
||||
providerField?: string;
|
||||
}
|
||||
|
||||
interface Condition {
|
||||
field: string;
|
||||
operator?: 'eq' | 'ne' | 'in' | 'nin' | 'gt' | 'lt' | 'gte' | 'lte' | 'exists';
|
||||
value?: any;
|
||||
}
|
||||
|
||||
interface RequiredInput {
|
||||
id: string;
|
||||
type?: 'password' | 'input' | 'select' | 'multiselect' | 'confirm' | 'editor' | 'number';
|
||||
label?: string;
|
||||
prompt?: string;
|
||||
placeholder?: string;
|
||||
options?: InputOption[] | DynamicOptions;
|
||||
when?: Condition | Condition[];
|
||||
defaultValue?: any;
|
||||
required?: boolean;
|
||||
validator?: RegExp | string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
rows?: number;
|
||||
dependsOn?: string[];
|
||||
}
|
||||
|
||||
interface PresetMetadata {
|
||||
id: string;
|
||||
@@ -34,9 +72,21 @@ interface PresetMetadata {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
interface PresetConfigSection {
|
||||
Providers?: Array<{
|
||||
name: string;
|
||||
api_base_url?: string;
|
||||
models?: string[];
|
||||
[key: string]: any;
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface PresetDetail extends PresetMetadata {
|
||||
config?: any;
|
||||
requiredInputs?: Array<{ field: string; prompt?: string; placeholder?: string }>;
|
||||
config?: PresetConfigSection;
|
||||
schema?: RequiredInput[];
|
||||
template?: any;
|
||||
configMappings?: any[];
|
||||
}
|
||||
|
||||
interface MarketPreset {
|
||||
@@ -145,13 +195,13 @@ export function Presets() {
|
||||
setSelectedPreset({ ...preset, ...detail });
|
||||
setDetailDialogOpen(true);
|
||||
|
||||
// 初始化 secrets
|
||||
if (detail.requiredInputs) {
|
||||
const initialSecrets: Record<string, string> = {};
|
||||
for (const input of detail.requiredInputs) {
|
||||
initialSecrets[input.field] = '';
|
||||
// 初始化默认值
|
||||
if (detail.schema && detail.schema.length > 0) {
|
||||
const initialValues: Record<string, any> = {};
|
||||
for (const input of detail.schema) {
|
||||
initialValues[input.id] = input.defaultValue ?? '';
|
||||
}
|
||||
setSecrets(initialSecrets);
|
||||
setSecrets(initialValues);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load preset details:', error);
|
||||
@@ -188,21 +238,27 @@ export function Presets() {
|
||||
};
|
||||
|
||||
// 应用预设(配置敏感信息)
|
||||
const handleApplyPreset = async () => {
|
||||
const handleApplyPreset = async (values?: Record<string, any>) => {
|
||||
try {
|
||||
setIsApplying(true);
|
||||
|
||||
// 使用传入的values或现有的secrets
|
||||
const inputValues = values || secrets;
|
||||
|
||||
// 验证所有必填项都已填写
|
||||
if (selectedPreset?.requiredInputs) {
|
||||
for (const input of selectedPreset.requiredInputs) {
|
||||
if (!secrets[input.field] || secrets[input.field].trim() === '') {
|
||||
setToast({ message: t('presets.please_fill_field', { field: input.field }), type: 'warning' });
|
||||
if (selectedPreset?.schema && selectedPreset.schema.length > 0) {
|
||||
// 验证在 DynamicConfigForm 中已完成
|
||||
// 这里只做简单检查
|
||||
for (const input of selectedPreset.schema) {
|
||||
if (input.required !== false && !inputValues[input.id]) {
|
||||
setToast({ message: t('presets.please_fill_field', { field: input.label || input.id }), type: 'warning' });
|
||||
setIsApplying(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await api.applyPreset(selectedPreset!.name, secrets);
|
||||
await api.applyPreset(selectedPreset!.name, inputValues);
|
||||
setToast({ message: t('presets.preset_applied'), type: 'success' });
|
||||
setDetailDialogOpen(false);
|
||||
setSecrets({});
|
||||
@@ -423,23 +479,18 @@ export function Presets() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPreset?.requiredInputs && selectedPreset.requiredInputs.length > 0 && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h4 className="font-medium text-sm">{t('presets.required_information')}</h4>
|
||||
{selectedPreset.requiredInputs.map((input) => (
|
||||
<div key={input.field} className="space-y-2">
|
||||
<Label htmlFor={`secret-${input.field}`}>
|
||||
{input.prompt || input.field}
|
||||
</Label>
|
||||
<Input
|
||||
id={`secret-${input.field}`}
|
||||
type="password"
|
||||
placeholder={input.placeholder || t('presets.please_fill_field', { field: input.field })}
|
||||
value={secrets[input.field] || ''}
|
||||
onChange={(e) => setSecrets({ ...secrets, [input.field]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 配置表单 */}
|
||||
{selectedPreset?.schema && selectedPreset.schema.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="font-medium text-sm mb-4">{t('presets.required_information')}</h4>
|
||||
<DynamicConfigForm
|
||||
schema={selectedPreset.schema}
|
||||
presetConfig={selectedPreset.config || {}}
|
||||
onSubmit={(values) => handleApplyPreset(values)}
|
||||
onCancel={() => setDetailDialogOpen(false)}
|
||||
isSubmitting={isApplying}
|
||||
initialValues={secrets}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -447,21 +498,6 @@ export function Presets() {
|
||||
<Button variant="outline" onClick={() => setDetailDialogOpen(false)}>
|
||||
{t('presets.close')}
|
||||
</Button>
|
||||
{selectedPreset?.requiredInputs && selectedPreset.requiredInputs.length > 0 && (
|
||||
<Button onClick={handleApplyPreset} disabled={isApplying}>
|
||||
{isApplying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('presets.applying')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t('presets.apply')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
453
packages/ui/src/components/preset/DynamicConfigForm.tsx
Normal file
453
packages/ui/src/components/preset/DynamicConfigForm.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
// 类型定义
|
||||
interface InputOption {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DynamicOptions {
|
||||
type: 'static' | 'providers' | 'models' | 'custom';
|
||||
options?: InputOption[];
|
||||
providerField?: string;
|
||||
}
|
||||
|
||||
interface Condition {
|
||||
field: string;
|
||||
operator?: 'eq' | 'ne' | 'in' | 'nin' | 'gt' | 'lt' | 'gte' | 'lte' | 'exists';
|
||||
value?: any;
|
||||
}
|
||||
|
||||
interface RequiredInput {
|
||||
id: string;
|
||||
type?: 'password' | 'input' | 'select' | 'multiselect' | 'confirm' | 'editor' | 'number';
|
||||
label?: string;
|
||||
prompt?: string;
|
||||
placeholder?: string;
|
||||
options?: InputOption[] | DynamicOptions;
|
||||
when?: Condition | Condition[];
|
||||
defaultValue?: any;
|
||||
required?: boolean;
|
||||
validator?: RegExp | string | ((value: any) => boolean | string);
|
||||
min?: number;
|
||||
max?: number;
|
||||
rows?: number;
|
||||
dependsOn?: string[];
|
||||
}
|
||||
|
||||
interface PresetConfigSection {
|
||||
Providers?: Array<{
|
||||
name: string;
|
||||
api_base_url?: string;
|
||||
models?: string[];
|
||||
[key: string]: any;
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface DynamicConfigFormProps {
|
||||
schema: RequiredInput[];
|
||||
presetConfig: PresetConfigSection;
|
||||
onSubmit: (values: Record<string, any>) => void;
|
||||
onCancel: () => void;
|
||||
isSubmitting?: boolean;
|
||||
initialValues?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function DynamicConfigForm({
|
||||
schema,
|
||||
presetConfig,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting = false,
|
||||
initialValues = {},
|
||||
}: DynamicConfigFormProps) {
|
||||
const [values, setValues] = useState<Record<string, any>>(initialValues);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [visibleFields, setVisibleFields] = useState<Set<string>>(new Set());
|
||||
|
||||
// 计算可见字段
|
||||
useEffect(() => {
|
||||
const updateVisibility = () => {
|
||||
const visible = new Set<string>();
|
||||
|
||||
for (const field of schema) {
|
||||
if (shouldShowField(field, values)) {
|
||||
visible.add(field.id);
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleFields(visible);
|
||||
};
|
||||
|
||||
updateVisibility();
|
||||
}, [values, schema]);
|
||||
|
||||
// 评估条件
|
||||
const evaluateCondition = (condition: Condition): boolean => {
|
||||
const actualValue = values[condition.field];
|
||||
|
||||
if (condition.operator === 'exists') {
|
||||
return actualValue !== undefined && actualValue !== null;
|
||||
}
|
||||
|
||||
if (condition.operator === 'in') {
|
||||
return Array.isArray(condition.value) && condition.value.includes(actualValue);
|
||||
}
|
||||
|
||||
if (condition.operator === 'nin') {
|
||||
return Array.isArray(condition.value) && !condition.value.includes(actualValue);
|
||||
}
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'eq':
|
||||
return actualValue === condition.value;
|
||||
case 'ne':
|
||||
return actualValue !== condition.value;
|
||||
case 'gt':
|
||||
return actualValue > condition.value;
|
||||
case 'lt':
|
||||
return actualValue < condition.value;
|
||||
case 'gte':
|
||||
return actualValue >= condition.value;
|
||||
case 'lte':
|
||||
return actualValue <= condition.value;
|
||||
default:
|
||||
return actualValue === condition.value;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断字段是否应该显示
|
||||
const shouldShowField = (field: RequiredInput): boolean => {
|
||||
if (!field.when) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const conditions = Array.isArray(field.when) ? field.when : [field.when];
|
||||
return conditions.every(condition => evaluateCondition(condition));
|
||||
};
|
||||
|
||||
// 获取选项列表
|
||||
const getOptions = (field: RequiredInput): InputOption[] => {
|
||||
if (!field.options) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = field.options as any;
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
return options as InputOption[];
|
||||
}
|
||||
|
||||
if (options.type === 'static') {
|
||||
return options.options || [];
|
||||
}
|
||||
|
||||
if (options.type === 'providers') {
|
||||
const providers = presetConfig.Providers || [];
|
||||
return providers.map((p) => ({
|
||||
label: p.name || p.id || String(p),
|
||||
value: p.name || p.id || String(p),
|
||||
description: p.api_base_url,
|
||||
}));
|
||||
}
|
||||
|
||||
if (options.type === 'models') {
|
||||
const providerField = options.providerField;
|
||||
if (!providerField) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const providerId = String(providerField).replace(/^{{(.+)}}$/, '$1');
|
||||
const selectedProvider = values[providerId];
|
||||
|
||||
if (!selectedProvider || !presetConfig.Providers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const provider = presetConfig.Providers.find(
|
||||
(p) => p.name === selectedProvider || p.id === selectedProvider
|
||||
);
|
||||
|
||||
if (!provider || !provider.models) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return provider.models.map((model: string) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// 更新字段值
|
||||
const updateValue = (fieldId: string, value: any) => {
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}));
|
||||
// 清除该字段的错误
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldId];
|
||||
return newErrors;
|
||||
});
|
||||
};
|
||||
|
||||
// 验证单个字段
|
||||
const validateField = (field: RequiredInput): string | null => {
|
||||
const value = values[field.id];
|
||||
|
||||
// 检查必填
|
||||
if (field.required !== false && (value === undefined || value === null || value === '')) {
|
||||
return `${field.label || field.id} is required`;
|
||||
}
|
||||
|
||||
if (!value && field.required === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 类型检查
|
||||
if (field.type === 'number' && isNaN(Number(value))) {
|
||||
return `${field.label || field.id} must be a number`;
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
const numValue = Number(value);
|
||||
if (field.min !== undefined && numValue < field.min) {
|
||||
return `${field.label || field.id} must be at least ${field.min}`;
|
||||
}
|
||||
if (field.max !== undefined && numValue > field.max) {
|
||||
return `${field.label || field.id} must be at most ${field.max}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义验证器
|
||||
if (field.validator) {
|
||||
if (field.validator instanceof RegExp) {
|
||||
if (!field.validator.test(String(value))) {
|
||||
return `${field.label || field.id} format is invalid`;
|
||||
}
|
||||
} else if (typeof field.validator === 'string') {
|
||||
const regex = new RegExp(field.validator);
|
||||
if (!regex.test(String(value))) {
|
||||
return `${field.label || field.id} format is invalid`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 验证所有可见字段
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
for (const field of schema) {
|
||||
if (!visibleFields.has(field.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const error = validateField(field);
|
||||
if (error) {
|
||||
newErrors[field.id] = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{schema.map((field) => {
|
||||
if (!visibleFields.has(field.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = field.label || field.id;
|
||||
const prompt = field.prompt;
|
||||
const error = errors[field.id];
|
||||
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label htmlFor={`field-${field.id}`}>
|
||||
{label}
|
||||
{field.required !== false && <span className="text-red-500 ml-1">*</span>}
|
||||
</Label>
|
||||
|
||||
{prompt && (
|
||||
<p className="text-sm text-gray-600">{prompt}</p>
|
||||
)}
|
||||
|
||||
{/* Password / Input */}
|
||||
{(field.type === 'password' || field.type === 'input' || !field.type) && (
|
||||
<Input
|
||||
id={`field-${field.id}`}
|
||||
type={field.type === 'password' ? 'password' : 'text'}
|
||||
placeholder={field.placeholder}
|
||||
value={values[field.id] || ''}
|
||||
onChange={(e) => updateValue(field.id, e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Number */}
|
||||
{field.type === 'number' && (
|
||||
<Input
|
||||
id={`field-${field.id}`}
|
||||
type="number"
|
||||
placeholder={field.placeholder}
|
||||
value={values[field.id] || ''}
|
||||
onChange={(e) => updateValue(field.id, Number(e.target.value))}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Select */}
|
||||
{field.type === 'select' && (
|
||||
<Select
|
||||
value={values[field.id] || ''}
|
||||
onValueChange={(value) => updateValue(field.id, value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id={`field-${field.id}`}>
|
||||
<SelectValue placeholder={field.placeholder || `Select ${label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getOptions(field).map((option) => (
|
||||
<SelectItem
|
||||
key={String(option.value)}
|
||||
value={String(option.value)}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<div>
|
||||
<div>{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="text-xs text-gray-500">{option.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* Multiselect */}
|
||||
{field.type === 'multiselect' && (
|
||||
<div className="space-y-2">
|
||||
{getOptions(field).map((option) => (
|
||||
<div key={String(option.value)} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`field-${field.id}-${option.value}`}
|
||||
checked={Array.isArray(values[field.id]) && values[field.id].includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = Array.isArray(values[field.id]) ? values[field.id] : [];
|
||||
if (checked) {
|
||||
updateValue(field.id, [...current, option.value]);
|
||||
} else {
|
||||
updateValue(field.id, current.filter((v: any) => v !== option.value));
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting || option.disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`field-${field.id}-${option.value}`}
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
{option.description && (
|
||||
<span className="text-gray-500 ml-2">{option.description}</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm */}
|
||||
{field.type === 'confirm' && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`field-${field.id}`}
|
||||
checked={values[field.id] || false}
|
||||
onCheckedChange={(checked) => updateValue(field.id, checked)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label htmlFor={`field-${field.id}`} className="text-sm font-normal cursor-pointer">
|
||||
{field.prompt || label}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
{field.type === 'editor' && (
|
||||
<Textarea
|
||||
id={`field-${field.id}`}
|
||||
placeholder={field.placeholder}
|
||||
value={values[field.id] || ''}
|
||||
onChange={(e) => updateValue(field.id, e.target.value)}
|
||||
rows={field.rows || 5}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Applying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Apply
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user