mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-01-29 22:02:05 +00:00
finished presets
This commit is contained in:
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* 预设命令处理器 CLI 层
|
||||
* 负责处理 CLI 交互,核心逻辑在 shared 包中
|
||||
* Preset command handler CLI layer
|
||||
* Handles CLI interactions, core logic is in the shared package
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as fsSync from 'fs';
|
||||
import * as path from 'path';
|
||||
import JSON5 from 'json5';
|
||||
import { exportPresetCli } from './export';
|
||||
import { installPresetCli, loadPreset } from './install';
|
||||
import { MergeStrategy, HOME_DIR } from '@CCR/shared';
|
||||
import { HOME_DIR } from '@CCR/shared';
|
||||
|
||||
// ANSI 颜色代码
|
||||
// ANSI color codes
|
||||
const RESET = "\x1B[0m";
|
||||
const GREEN = "\x1B[32m";
|
||||
const YELLOW = "\x1B[33m";
|
||||
@@ -20,7 +19,7 @@ const BOLDYELLOW = "\x1B[1m\x1B[33m";
|
||||
const DIM = "\x1B[2m";
|
||||
|
||||
/**
|
||||
* 列出本地预设
|
||||
* List local presets
|
||||
*/
|
||||
async function listPresets(): Promise<void> {
|
||||
const presetsDir = path.join(HOME_DIR, 'presets');
|
||||
@@ -51,7 +50,7 @@ async function listPresets(): Promise<void> {
|
||||
const content = await fs.readFile(manifestPath, 'utf-8');
|
||||
const manifest = JSON5.parse(content);
|
||||
|
||||
// 从manifest中提取metadata字段
|
||||
// Extract metadata fields from manifest
|
||||
const { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE, requiredInputs, ...metadata } = manifest;
|
||||
|
||||
const name = metadata.name || dirName;
|
||||
@@ -59,19 +58,19 @@ async function listPresets(): Promise<void> {
|
||||
const author = metadata.author || '';
|
||||
const version = metadata.version;
|
||||
|
||||
// 显示预设名称
|
||||
// Display preset name
|
||||
if (version) {
|
||||
console.log(`${GREEN}•${RESET} ${BOLDCYAN}${name}${RESET} (v${version})`);
|
||||
} else {
|
||||
console.log(`${GREEN}•${RESET} ${BOLDCYAN}${name}${RESET}`);
|
||||
}
|
||||
|
||||
// 显示描述
|
||||
// Display description
|
||||
if (description) {
|
||||
console.log(` ${description}`);
|
||||
}
|
||||
|
||||
// 显示作者
|
||||
// Display author
|
||||
if (author) {
|
||||
console.log(` ${DIM}by ${author}${RESET}`);
|
||||
}
|
||||
@@ -85,14 +84,21 @@ async function listPresets(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除预设
|
||||
* Delete preset
|
||||
*/
|
||||
async function deletePreset(name: string): Promise<void> {
|
||||
const presetsDir = path.join(HOME_DIR, 'presets');
|
||||
|
||||
// Validate preset name (prevent path traversal)
|
||||
if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
|
||||
console.error(`\n${YELLOW}Error:${RESET} Invalid preset name.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const presetDir = path.join(presetsDir, name);
|
||||
|
||||
try {
|
||||
// 递归删除整个目录
|
||||
// Recursively delete entire directory
|
||||
await fs.rm(presetDir, { recursive: true, force: true });
|
||||
console.log(`\n${GREEN}✓${RESET} Preset "${name}" deleted.\n`);
|
||||
} catch (error: any) {
|
||||
@@ -106,27 +112,27 @@ async function deletePreset(name: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示预设信息
|
||||
* Show preset information
|
||||
*/
|
||||
async function showPresetInfo(name: string): Promise<void> {
|
||||
try {
|
||||
const preset = await loadPreset(name);
|
||||
|
||||
const config = preset.config;
|
||||
const metadata = preset.metadata || {};
|
||||
const metadata = preset.metadata;
|
||||
|
||||
console.log(`\n${BOLDCYAN}═══════════════════════════════════════════════${RESET}`);
|
||||
if (metadata.name) {
|
||||
if (metadata?.name) {
|
||||
console.log(`${BOLDCYAN}Preset: ${RESET}${metadata.name}`);
|
||||
} else {
|
||||
console.log(`${BOLDCYAN}Preset: ${RESET}${name}`);
|
||||
}
|
||||
console.log(`${BOLDCYAN}═══════════════════════════════════════════════${RESET}\n`);
|
||||
|
||||
if (metadata.version) console.log(`${BOLDCYAN}Version:${RESET} ${metadata.version}`);
|
||||
if (metadata.description) console.log(`${BOLDCYAN}Description:${RESET} ${metadata.description}`);
|
||||
if (metadata.author) console.log(`${BOLDCYAN}Author:${RESET} ${metadata.author}`);
|
||||
const keywords = (metadata as any).keywords;
|
||||
if (metadata?.version) console.log(`${BOLDCYAN}Version:${RESET} ${metadata.version}`);
|
||||
if (metadata?.description) console.log(`${BOLDCYAN}Description:${RESET} ${metadata.description}`);
|
||||
if (metadata?.author) console.log(`${BOLDCYAN}Author:${RESET} ${metadata.author}`);
|
||||
const keywords = metadata?.keywords;
|
||||
if (keywords && keywords.length > 0) {
|
||||
console.log(`${BOLDCYAN}Keywords:${RESET} ${keywords.join(', ')}`);
|
||||
}
|
||||
@@ -142,11 +148,12 @@ async function showPresetInfo(name: string): Promise<void> {
|
||||
console.log(` Provider: ${config.provider}`);
|
||||
}
|
||||
|
||||
if (preset.requiredInputs && preset.requiredInputs.length > 0) {
|
||||
if (preset.schema && preset.schema.length > 0) {
|
||||
console.log(`\n${BOLDYELLOW}Required inputs:${RESET}`);
|
||||
for (const input of preset.requiredInputs) {
|
||||
const envVar = input.placeholder || input.field;
|
||||
console.log(` - ${input.field} ${DIM}(${envVar})${RESET}`);
|
||||
for (const input of preset.schema) {
|
||||
const label = input.label || input.id;
|
||||
const prompt = input.prompt || '';
|
||||
console.log(` - ${label}${prompt ? ` ${DIM}(${prompt})${RESET}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +165,7 @@ async function showPresetInfo(name: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预设命令
|
||||
* Handle preset commands
|
||||
*/
|
||||
export async function handlePresetCommand(args: string[]): Promise<void> {
|
||||
const subCommand = args[0];
|
||||
@@ -172,7 +179,7 @@ export async function handlePresetCommand(args: string[]): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 解析选项
|
||||
// Parse options
|
||||
const options: any = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--output' && args[i + 1]) {
|
||||
@@ -199,22 +206,7 @@ export async function handlePresetCommand(args: string[]): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 解析选项
|
||||
const installOptions: any = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--strategy' && args[i + 1]) {
|
||||
const strategy = args[++i];
|
||||
if (['ask', 'overwrite', 'merge', 'skip'].includes(strategy)) {
|
||||
installOptions.strategy = strategy as MergeStrategy;
|
||||
} else {
|
||||
console.error(`\nError: Invalid merge strategy "${strategy}"\n`);
|
||||
console.error('Valid strategies: ask, overwrite, merge, skip\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await installPresetCli(source, installOptions);
|
||||
await installPresetCli(source, {});
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* 预设安装功能 CLI 层
|
||||
* 负责处理 CLI 交互,核心逻辑在 shared 包中
|
||||
* Preset installation functionality CLI layer
|
||||
* Handles CLI interactions, core logic is in the shared package
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { password, confirm } from '@inquirer/prompts';
|
||||
import {
|
||||
loadPreset as loadPresetShared,
|
||||
validatePreset,
|
||||
@@ -19,18 +18,14 @@ import {
|
||||
isPresetInstalled,
|
||||
ManifestFile,
|
||||
PresetFile,
|
||||
RequiredInput,
|
||||
UserInputValues,
|
||||
applyConfigMappings,
|
||||
replaceTemplateVariables,
|
||||
setValueByPath,
|
||||
} from '@CCR/shared';
|
||||
import { collectUserInputs } from '../prompt/schema-input';
|
||||
|
||||
// 重新导出 loadPreset
|
||||
// Re-export loadPreset
|
||||
export { loadPresetShared as loadPreset };
|
||||
|
||||
// ANSI 颜色代码
|
||||
// ANSI color codes
|
||||
const RESET = "\x1B[0m";
|
||||
const GREEN = "\x1B[32m";
|
||||
const BOLDGREEN = "\x1B[1m\x1B[32m";
|
||||
@@ -40,44 +35,9 @@ const BOLDCYAN = "\x1B[1m\x1B[36m";
|
||||
const DIM = "\x1B[2m";
|
||||
|
||||
/**
|
||||
* 应用用户输入到配置(新版schema)
|
||||
*/
|
||||
function applyUserInputs(
|
||||
preset: PresetFile,
|
||||
values: UserInputValues
|
||||
): PresetConfigSection {
|
||||
let config = { ...preset.config };
|
||||
|
||||
// 1. 先应用 template(如果存在)
|
||||
if (preset.template) {
|
||||
config = replaceTemplateVariables(preset.template, values) as any;
|
||||
}
|
||||
|
||||
// 2. 再应用 configMappings(如果存在)
|
||||
if (preset.configMappings && preset.configMappings.length > 0) {
|
||||
config = applyConfigMappings(preset.configMappings, values, config);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用预设到配置
|
||||
* @param presetName 预设名称
|
||||
* @param preset 预设对象
|
||||
* Apply preset to configuration
|
||||
* @param presetName Preset name
|
||||
* @param preset Preset object
|
||||
*/
|
||||
export async function applyPresetCli(
|
||||
presetName: string,
|
||||
@@ -86,7 +46,7 @@ export async function applyPresetCli(
|
||||
try {
|
||||
console.log(`${BOLDCYAN}Loading preset...${RESET} ${GREEN}✓${RESET}`);
|
||||
|
||||
// 验证预设
|
||||
// Validate preset
|
||||
const validation = await validatePreset(preset);
|
||||
if (validation.warnings.length > 0) {
|
||||
console.log(`\n${YELLOW}Warnings:${RESET}`);
|
||||
@@ -105,36 +65,35 @@ export async function applyPresetCli(
|
||||
|
||||
console.log(`${BOLDCYAN}Validating preset...${RESET} ${GREEN}✓${RESET}`);
|
||||
|
||||
// 检查是否需要配置
|
||||
// Check if configuration is required
|
||||
if (preset.schema && preset.schema.length > 0) {
|
||||
console.log(`\n${BOLDCYAN}Configuration required:${RESET} ${preset.schema.length} field(s)\n`);
|
||||
} else {
|
||||
console.log(`\n${DIM}No configuration required for this preset${RESET}\n`);
|
||||
}
|
||||
|
||||
// 收集用户输入
|
||||
// Collect user inputs
|
||||
let userInputs: UserInputValues = {};
|
||||
|
||||
// 使用 schema 系统
|
||||
// Use schema system
|
||||
if (preset.schema && preset.schema.length > 0) {
|
||||
userInputs = await collectUserInputs(preset.schema, preset.config);
|
||||
}
|
||||
|
||||
// 应用用户输入到配置
|
||||
const finalConfig = applyUserInputs(preset, userInputs);
|
||||
|
||||
// 读取现有的manifest并更新
|
||||
// Build manifest, keep original config, store user values in userValues
|
||||
const manifest: ManifestFile = {
|
||||
name: presetName,
|
||||
version: preset.metadata?.version || '1.0.0',
|
||||
...(preset.metadata || {}),
|
||||
...finalConfig,
|
||||
...preset.config, // Keep original config (may contain placeholders)
|
||||
};
|
||||
|
||||
// 保存 schema(如果存在)
|
||||
// Save schema (if exists)
|
||||
if (preset.schema) {
|
||||
manifest.schema = preset.schema;
|
||||
}
|
||||
|
||||
// 保存其他配置
|
||||
// Save other configurations
|
||||
if (preset.template) {
|
||||
manifest.template = preset.template;
|
||||
}
|
||||
@@ -142,10 +101,17 @@ export async function applyPresetCli(
|
||||
manifest.configMappings = preset.configMappings;
|
||||
}
|
||||
|
||||
// 保存到解压目录的manifest.json
|
||||
// Save user-filled values to userValues
|
||||
if (Object.keys(userInputs).length > 0) {
|
||||
manifest.userValues = userInputs;
|
||||
}
|
||||
|
||||
// Save to manifest.json in extracted directory
|
||||
await saveManifest(presetName, manifest);
|
||||
|
||||
// 显示摘要
|
||||
const presetDir = getPresetDir(presetName);
|
||||
|
||||
// Display summary
|
||||
console.log(`\n${BOLDGREEN}✓ Preset configured successfully!${RESET}\n`);
|
||||
console.log(`${BOLDCYAN}Preset directory:${RESET} ${presetDir}`);
|
||||
console.log(`${BOLDCYAN}Inputs configured:${RESET} ${Object.keys(userInputs).length}`);
|
||||
@@ -173,7 +139,7 @@ export async function applyPresetCli(
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装预设(主入口)
|
||||
* Install preset (main entry point)
|
||||
*/
|
||||
export async function installPresetCli(
|
||||
source: string,
|
||||
@@ -182,37 +148,32 @@ export async function installPresetCli(
|
||||
name?: string;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
let tempFile: string | null = null;
|
||||
try {
|
||||
// 确定预设名称
|
||||
// Determine preset name
|
||||
let presetName = options.name;
|
||||
let sourceZip: string;
|
||||
let isReconfigure = false; // 是否是重新配置已安装的preset
|
||||
let sourceZip: string | undefined;
|
||||
let isReconfigure = false; // Whether to reconfigure installed preset
|
||||
|
||||
// 判断source类型并获取ZIP文件路径
|
||||
// Determine source type and get ZIP file path
|
||||
if (source.startsWith('http://') || source.startsWith('https://')) {
|
||||
// URL:下载到临时文件
|
||||
// URL: download to temp file
|
||||
if (!presetName) {
|
||||
const urlParts = source.split('/');
|
||||
const filename = urlParts[urlParts.length - 1];
|
||||
presetName = filename.replace('.ccrsets', '');
|
||||
}
|
||||
// 这里直接从 shared 包导入的 downloadPresetToTemp 会返回临时文件
|
||||
// 但我们会在 loadPreset 中自动清理,所以不需要在这里处理
|
||||
const preset = await loadPreset(source);
|
||||
if (!presetName) {
|
||||
presetName = preset.metadata?.name || 'preset';
|
||||
}
|
||||
// 重新下载到临时文件以供 extractPreset 使用
|
||||
// 由于 loadPreset 已经下载并删除了,这里需要特殊处理
|
||||
// downloadPresetToTemp imported from shared package will return temp file
|
||||
// but we'll auto-cleanup in loadPreset, so no need to handle here
|
||||
// Re-download to temp file for extractPreset usage
|
||||
// Since loadPreset already downloaded and deleted, special handling needed here
|
||||
throw new Error('URL installation not fully implemented yet');
|
||||
} else if (source.includes('/') || source.includes('\\')) {
|
||||
// 文件路径
|
||||
// File path
|
||||
if (!presetName) {
|
||||
const filename = path.basename(source);
|
||||
presetName = filename.replace('.ccrsets', '');
|
||||
}
|
||||
// 验证文件存在
|
||||
// Verify file exists
|
||||
try {
|
||||
await fs.access(source);
|
||||
} catch {
|
||||
@@ -220,48 +181,51 @@ export async function installPresetCli(
|
||||
}
|
||||
sourceZip = source;
|
||||
} else {
|
||||
// 预设名称(不带路径)
|
||||
// Preset name (without path)
|
||||
presetName = source;
|
||||
|
||||
// 按优先级查找文件:当前目录 -> presets目录
|
||||
// Search files by priority: current directory -> presets directory
|
||||
const presetFile = await findPresetFile(source);
|
||||
|
||||
if (presetFile) {
|
||||
sourceZip = presetFile;
|
||||
} else {
|
||||
// 检查是否已安装(目录存在)
|
||||
// Check if already installed (directory exists)
|
||||
if (await isPresetInstalled(source)) {
|
||||
// 已安装,重新配置
|
||||
// Already installed, reconfigure
|
||||
isReconfigure = true;
|
||||
} else {
|
||||
// 都不存在,报错
|
||||
// Neither exists, error
|
||||
throw new Error(`Preset '${source}' not found in current directory or presets directory.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isReconfigure) {
|
||||
// 重新配置已安装的preset
|
||||
// Reconfigure installed preset
|
||||
console.log(`${BOLDCYAN}Reconfiguring preset:${RESET} ${presetName}\n`);
|
||||
|
||||
const presetDir = getPresetDir(presetName);
|
||||
const manifest = await readManifestFromDir(presetDir);
|
||||
const preset = manifestToPresetFile(manifest);
|
||||
|
||||
// 应用preset(会询问敏感信息)
|
||||
// Apply preset (will ask for sensitive info)
|
||||
await applyPresetCli(presetName, preset);
|
||||
} else {
|
||||
// 新安装:解压到目标目录
|
||||
// New installation: extract to target directory
|
||||
if (!sourceZip) {
|
||||
throw new Error('Source ZIP file is required for installation');
|
||||
}
|
||||
const targetDir = getPresetDir(presetName);
|
||||
console.log(`${BOLDCYAN}Extracting preset to:${RESET} ${targetDir}`);
|
||||
await extractPreset(sourceZip, targetDir);
|
||||
console.log(`${GREEN}✓${RESET} Extracted successfully\n`);
|
||||
|
||||
// 从解压目录读取manifest
|
||||
// Read manifest from extracted directory
|
||||
const manifest = await readManifestFromDir(targetDir);
|
||||
const preset = manifestToPresetFile(manifest);
|
||||
|
||||
// 应用preset(询问用户信息等)
|
||||
// Apply preset (ask user info, etc.)
|
||||
await applyPresetCli(presetName, preset);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,28 @@ import { RegisterProviderRequest, LLMProvider } from "@/types/llm";
|
||||
import { sendUnifiedRequest } from "@/utils/request";
|
||||
import { createApiError } from "./middleware";
|
||||
import { version } from "../../package.json";
|
||||
import { ConfigService } from "@/services/config";
|
||||
import { ProviderService } from "@/services/provider";
|
||||
import { TransformerService } from "@/services/transformer";
|
||||
import { Transformer } from "@/types/transformer";
|
||||
|
||||
// Extend FastifyInstance to include custom services
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
configService: ConfigService;
|
||||
providerService: ProviderService;
|
||||
transformerService: TransformerService;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
provider?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理transformer端点的主函数
|
||||
* 协调整个请求处理流程:验证提供者、处理请求转换器、发送请求、处理响应转换器、格式化响应
|
||||
* Main handler for transformer endpoints
|
||||
* Coordinates the entire request processing flow: validate provider, handle request transformers,
|
||||
* send request, handle response transformers, format response
|
||||
*/
|
||||
async function handleTransformerEndpoint(
|
||||
req: FastifyRequest,
|
||||
@@ -21,9 +39,9 @@ async function handleTransformerEndpoint(
|
||||
) {
|
||||
const body = req.body as any;
|
||||
const providerName = req.provider!;
|
||||
const provider = fastify._server!.providerService.getProvider(providerName);
|
||||
const provider = fastify.providerService.getProvider(providerName);
|
||||
|
||||
// 验证提供者是否存在
|
||||
// Validate provider exists
|
||||
if (!provider) {
|
||||
throw createApiError(
|
||||
`Provider '${providerName}' not found`,
|
||||
@@ -32,7 +50,7 @@ async function handleTransformerEndpoint(
|
||||
);
|
||||
}
|
||||
|
||||
// 处理请求转换器链
|
||||
// Process request transformer chain
|
||||
const { requestBody, config, bypass } = await processRequestTransformers(
|
||||
body,
|
||||
provider,
|
||||
@@ -43,7 +61,7 @@ async function handleTransformerEndpoint(
|
||||
}
|
||||
);
|
||||
|
||||
// 发送请求到LLM提供者
|
||||
// Send request to LLM provider
|
||||
const response = await sendRequestToProvider(
|
||||
requestBody,
|
||||
config,
|
||||
@@ -56,7 +74,7 @@ async function handleTransformerEndpoint(
|
||||
}
|
||||
);
|
||||
|
||||
// 处理响应转换器链
|
||||
// Process response transformer chain
|
||||
const finalResponse = await processResponseTransformers(
|
||||
requestBody,
|
||||
response,
|
||||
@@ -68,14 +86,14 @@ async function handleTransformerEndpoint(
|
||||
}
|
||||
);
|
||||
|
||||
// 格式化并返回响应
|
||||
// Format and return response
|
||||
return formatResponse(finalResponse, reply, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理请求转换器链
|
||||
* 依次执行transformRequestOut、provider transformers、model-specific transformers
|
||||
* 返回处理后的请求体、配置和是否跳过转换器的标志
|
||||
* Process request transformer chain
|
||||
* Sequentially execute transformRequestOut, provider transformers, model-specific transformers
|
||||
* Returns processed request body, config, and flag indicating whether to skip transformers
|
||||
*/
|
||||
async function processRequestTransformers(
|
||||
body: any,
|
||||
@@ -88,7 +106,7 @@ async function processRequestTransformers(
|
||||
let config: any = {};
|
||||
let bypass = false;
|
||||
|
||||
// 检查是否应该跳过转换器(透传参数)
|
||||
// Check if transformers should be bypassed (passthrough mode)
|
||||
bypass = shouldBypassTransformers(provider, transformer, body);
|
||||
|
||||
if (bypass) {
|
||||
@@ -100,7 +118,7 @@ async function processRequestTransformers(
|
||||
config.headers = headers;
|
||||
}
|
||||
|
||||
// 执行transformer的transformRequestOut方法
|
||||
// Execute transformer's transformRequestOut method
|
||||
if (!bypass && typeof transformer.transformRequestOut === "function") {
|
||||
const transformOut = await transformer.transformRequestOut(requestBody);
|
||||
if (transformOut.body) {
|
||||
@@ -111,7 +129,7 @@ async function processRequestTransformers(
|
||||
}
|
||||
}
|
||||
|
||||
// 执行provider级别的转换器
|
||||
// Execute provider-level transformers
|
||||
if (!bypass && provider.transformer?.use?.length) {
|
||||
for (const providerTransformer of provider.transformer.use) {
|
||||
if (
|
||||
@@ -134,7 +152,7 @@ async function processRequestTransformers(
|
||||
}
|
||||
}
|
||||
|
||||
// 执行模型特定的转换器
|
||||
// Execute model-specific transformers
|
||||
if (!bypass && provider.transformer?.[body.model]?.use?.length) {
|
||||
for (const modelTransformer of provider.transformer[body.model].use) {
|
||||
if (
|
||||
@@ -155,8 +173,8 @@ async function processRequestTransformers(
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该跳过转换器(透传参数)
|
||||
* 当provider只使用一个transformer且该transformer与当前transformer相同时,跳过其他转换器
|
||||
* Determine if transformers should be bypassed (passthrough mode)
|
||||
* Skip other transformers when provider only uses one transformer and it matches the current one
|
||||
*/
|
||||
function shouldBypassTransformers(
|
||||
provider: any,
|
||||
@@ -173,8 +191,8 @@ function shouldBypassTransformers(
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送请求到LLM提供者
|
||||
* 处理认证、构建请求配置、发送请求并处理错误
|
||||
* Send request to LLM provider
|
||||
* Handle authentication, build request config, send request and handle errors
|
||||
*/
|
||||
async function sendRequestToProvider(
|
||||
requestBody: any,
|
||||
@@ -187,7 +205,7 @@ async function sendRequestToProvider(
|
||||
) {
|
||||
const url = config.url || new URL(provider.baseUrl);
|
||||
|
||||
// 在透传参数下处理认证
|
||||
// Handle authentication in passthrough mode
|
||||
if (bypass && typeof transformer.auth === "function") {
|
||||
const auth = await transformer.auth(requestBody, provider);
|
||||
if (auth.body) {
|
||||
@@ -211,8 +229,8 @@ async function sendRequestToProvider(
|
||||
}
|
||||
}
|
||||
|
||||
// 发送HTTP请求
|
||||
// 准备headers
|
||||
// Send HTTP request
|
||||
// Prepare headers
|
||||
const requestHeaders: Record<string, string> = {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
...(config?.headers || {}),
|
||||
@@ -233,7 +251,7 @@ async function sendRequestToProvider(
|
||||
url,
|
||||
requestBody,
|
||||
{
|
||||
httpsProxy: fastify._server!.configService.getHttpsProxy(),
|
||||
httpsProxy: fastify.configService.getHttpsProxy(),
|
||||
...config,
|
||||
headers: JSON.parse(JSON.stringify(requestHeaders)),
|
||||
},
|
||||
@@ -241,7 +259,7 @@ async function sendRequestToProvider(
|
||||
fastify.log
|
||||
);
|
||||
|
||||
// 处理请求错误
|
||||
// Handle request errors
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
fastify.log.error(
|
||||
@@ -258,8 +276,8 @@ async function sendRequestToProvider(
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应转换器链
|
||||
* 依次执行provider transformers、model-specific transformers、transformer的transformResponseIn
|
||||
* Process response transformer chain
|
||||
* Sequentially execute provider transformers, model-specific transformers, transformer's transformResponseIn
|
||||
*/
|
||||
async function processResponseTransformers(
|
||||
requestBody: any,
|
||||
@@ -271,43 +289,43 @@ async function processResponseTransformers(
|
||||
) {
|
||||
let finalResponse = response;
|
||||
|
||||
// 执行provider级别的响应转换器
|
||||
// Execute provider-level response transformers
|
||||
if (!bypass && provider.transformer?.use?.length) {
|
||||
for (const providerTransformer of Array.from(
|
||||
provider.transformer.use
|
||||
).reverse()) {
|
||||
).reverse() as Transformer[]) {
|
||||
if (
|
||||
!providerTransformer ||
|
||||
typeof providerTransformer.transformResponseOut !== "function"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
finalResponse = await providerTransformer.transformResponseOut(
|
||||
finalResponse = await providerTransformer.transformResponseOut!(
|
||||
finalResponse,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行模型特定的响应转换器
|
||||
// Execute model-specific response transformers
|
||||
if (!bypass && provider.transformer?.[requestBody.model]?.use?.length) {
|
||||
for (const modelTransformer of Array.from(
|
||||
provider.transformer[requestBody.model].use
|
||||
).reverse()) {
|
||||
).reverse() as Transformer[]) {
|
||||
if (
|
||||
!modelTransformer ||
|
||||
typeof modelTransformer.transformResponseOut !== "function"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
finalResponse = await modelTransformer.transformResponseOut(
|
||||
finalResponse = await modelTransformer.transformResponseOut!(
|
||||
finalResponse,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行transformer的transformResponseIn方法
|
||||
// Execute transformer's transformResponseIn method
|
||||
if (!bypass && transformer.transformResponseIn) {
|
||||
finalResponse = await transformer.transformResponseIn(
|
||||
finalResponse,
|
||||
@@ -319,16 +337,16 @@ async function processResponseTransformers(
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化并返回响应
|
||||
* 处理HTTP状态码、流式响应和普通响应的格式化
|
||||
* Format and return response
|
||||
* Handle HTTP status codes, format streaming and regular responses
|
||||
*/
|
||||
function formatResponse(response: any, reply: FastifyReply, body: any) {
|
||||
// 设置HTTP状态码
|
||||
// Set HTTP status code
|
||||
if (!response.ok) {
|
||||
reply.code(response.status);
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
// Handle streaming response
|
||||
const isStream = body.stream === true;
|
||||
if (isStream) {
|
||||
reply.header("Content-Type", "text/event-stream");
|
||||
@@ -336,12 +354,12 @@ function formatResponse(response: any, reply: FastifyReply, body: any) {
|
||||
reply.header("Connection", "keep-alive");
|
||||
return reply.send(response.body);
|
||||
} else {
|
||||
// 处理普通JSON响应
|
||||
// Handle regular JSON response
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const registerApiRoutes: FastifyPluginAsync = async (
|
||||
export const registerApiRoutes = async (
|
||||
fastify: FastifyInstance
|
||||
) => {
|
||||
// Health and info endpoints
|
||||
@@ -354,7 +372,7 @@ export const registerApiRoutes: FastifyPluginAsync = async (
|
||||
});
|
||||
|
||||
const transformersWithEndpoint =
|
||||
fastify._server!.transformerService.getTransformersWithEndpoint();
|
||||
fastify.transformerService.getTransformersWithEndpoint();
|
||||
|
||||
for (const { transformer } of transformersWithEndpoint) {
|
||||
if (transformer.endPoint) {
|
||||
@@ -421,7 +439,7 @@ export const registerApiRoutes: FastifyPluginAsync = async (
|
||||
}
|
||||
|
||||
// Check if provider already exists
|
||||
if (fastify._server!.providerService.getProvider(request.body.name)) {
|
||||
if (fastify.providerService.getProvider(request.body.name)) {
|
||||
throw createApiError(
|
||||
`Provider with name '${request.body.name}' already exists`,
|
||||
400,
|
||||
@@ -429,12 +447,12 @@ export const registerApiRoutes: FastifyPluginAsync = async (
|
||||
);
|
||||
}
|
||||
|
||||
return fastify._server!.providerService.registerProvider(request.body);
|
||||
return fastify.providerService.registerProvider(request.body);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get("/providers", async () => {
|
||||
return fastify._server!.providerService.getProviders();
|
||||
return fastify.providerService.getProviders();
|
||||
});
|
||||
|
||||
fastify.get(
|
||||
@@ -449,7 +467,7 @@ export const registerApiRoutes: FastifyPluginAsync = async (
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>) => {
|
||||
const provider = fastify._server!.providerService.getProvider(
|
||||
const provider = fastify.providerService.getProvider(
|
||||
request.params.id
|
||||
);
|
||||
if (!provider) {
|
||||
@@ -488,7 +506,7 @@ export const registerApiRoutes: FastifyPluginAsync = async (
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
const provider = fastify._server!.providerService.updateProvider(
|
||||
const provider = fastify.providerService.updateProvider(
|
||||
request.params.id,
|
||||
request.body
|
||||
);
|
||||
@@ -511,7 +529,7 @@ export const registerApiRoutes: FastifyPluginAsync = async (
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>) => {
|
||||
const success = fastify._server!.providerService.deleteProvider(
|
||||
const success = fastify.providerService.deleteProvider(
|
||||
request.params.id
|
||||
);
|
||||
if (!success) {
|
||||
@@ -544,7 +562,7 @@ export const registerApiRoutes: FastifyPluginAsync = async (
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
const success = fastify._server!.providerService.toggleProvider(
|
||||
const success = fastify.providerService.toggleProvider(
|
||||
request.params.id,
|
||||
request.body.enabled
|
||||
);
|
||||
|
||||
@@ -118,9 +118,23 @@ class Server {
|
||||
this.app.addHook(hookName as any, hookFunction);
|
||||
}
|
||||
|
||||
public async registerNamespace(name: string, options: any) {
|
||||
public async registerNamespace(name: string, options?: any) {
|
||||
if (!name) throw new Error("name is required");
|
||||
const configService = new ConfigService(options);
|
||||
if (name === '/') {
|
||||
await this.app.register(async (fastify) => {
|
||||
fastify.decorate('configService', this.configService);
|
||||
fastify.decorate('transformerService', this.transformerService);
|
||||
fastify.decorate('providerService', this.providerService);
|
||||
await registerApiRoutes(fastify);
|
||||
});
|
||||
return
|
||||
}
|
||||
if (!options) throw new Error("options is required");
|
||||
const configService = new ConfigService({
|
||||
initialConfig: {
|
||||
providers: options.Providers,
|
||||
}
|
||||
});
|
||||
const transformerService = new TransformerService(
|
||||
configService,
|
||||
this.app.log
|
||||
@@ -131,12 +145,17 @@ class Server {
|
||||
transformerService,
|
||||
this.app.log
|
||||
);
|
||||
this.app.register((fastify) => {
|
||||
// await this.app.register((fastify) => {
|
||||
// fastify.decorate('configService', configService);
|
||||
// fastify.decorate('transformerService', transformerService);
|
||||
// fastify.decorate('providerService', providerService);
|
||||
// }, { prefix: name });
|
||||
await this.app.register(async (fastify) => {
|
||||
fastify.decorate('configService', configService);
|
||||
fastify.decorate('transformerService', transformerService);
|
||||
fastify.decorate('providerService', providerService);
|
||||
await registerApiRoutes(fastify);
|
||||
}, { prefix: name });
|
||||
this.app.register(registerApiRoutes, { prefix: name });
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -179,7 +198,7 @@ class Server {
|
||||
}
|
||||
);
|
||||
|
||||
this.app.register(registerApiRoutes);
|
||||
await this.registerNamespace('/')
|
||||
|
||||
const address = await this.app.listen({
|
||||
port: parseInt(this.configService.get("PORT") || "3000", 10),
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
downloadPresetToTemp,
|
||||
getTempDir,
|
||||
HOME_DIR,
|
||||
extractMetadata,
|
||||
loadConfigFromManifest,
|
||||
type PresetFile,
|
||||
type ManifestFile,
|
||||
type PresetMetadata,
|
||||
@@ -81,7 +83,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
return reply.redirect("/ui/");
|
||||
});
|
||||
|
||||
// 获取日志文件列表端点
|
||||
// Get log file list endpoint
|
||||
app.get("/api/logs/files", async (req: any, reply: any) => {
|
||||
try {
|
||||
const logDir = join(homedir(), ".claude-code-router", "logs");
|
||||
@@ -104,7 +106,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
}
|
||||
|
||||
// 按修改时间倒序排列
|
||||
// Sort by modification time in descending order
|
||||
logFiles.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
}
|
||||
|
||||
@@ -115,17 +117,17 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取日志内容端点
|
||||
// Get log content endpoint
|
||||
app.get("/api/logs", async (req: any, reply: any) => {
|
||||
try {
|
||||
const filePath = (req.query as any).file as string;
|
||||
let logFilePath: string;
|
||||
|
||||
if (filePath) {
|
||||
// 如果指定了文件路径,使用指定的路径
|
||||
// If file path is specified, use the specified path
|
||||
logFilePath = filePath;
|
||||
} else {
|
||||
// 如果没有指定文件路径,使用默认的日志文件路径
|
||||
// If file path is not specified, use default log file path
|
||||
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
|
||||
}
|
||||
|
||||
@@ -143,17 +145,17 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 清除日志内容端点
|
||||
// Clear log content endpoint
|
||||
app.delete("/api/logs", async (req: any, reply: any) => {
|
||||
try {
|
||||
const filePath = (req.query as any).file as string;
|
||||
let logFilePath: string;
|
||||
|
||||
if (filePath) {
|
||||
// 如果指定了文件路径,使用指定的路径
|
||||
// If file path is specified, use the specified path
|
||||
logFilePath = filePath;
|
||||
} else {
|
||||
// 如果没有指定文件路径,使用默认的日志文件路径
|
||||
// If file path is not specified, use default log file path
|
||||
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
|
||||
}
|
||||
|
||||
@@ -168,7 +170,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取预设列表
|
||||
// Get presets list
|
||||
app.get("/api/presets", async (req: any, reply: any) => {
|
||||
try {
|
||||
const presetsDir = join(HOME_DIR, "presets");
|
||||
@@ -189,11 +191,11 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
const content = readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(content);
|
||||
|
||||
// 提取 metadata 字段
|
||||
// Extract metadata fields
|
||||
const { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE, requiredInputs, ...metadata } = manifest;
|
||||
|
||||
presets.push({
|
||||
id: dirName, // 目录名作为唯一标识
|
||||
id: dirName, // Use directory name as unique identifier
|
||||
name: metadata.name || dirName,
|
||||
version: metadata.version || '1.0.0',
|
||||
description: metadata.description,
|
||||
@@ -220,7 +222,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取预设详情
|
||||
// Get preset details
|
||||
app.get("/api/presets/:name", async (req: any, reply: any) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
@@ -232,39 +234,44 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
|
||||
const manifest = await readManifestFromDir(presetDir);
|
||||
const preset = manifestToPresetFile(manifest);
|
||||
const presetFile = manifestToPresetFile(manifest);
|
||||
|
||||
return preset;
|
||||
// Return preset info, config uses the applied userValues configuration
|
||||
return {
|
||||
...presetFile,
|
||||
config: loadConfigFromManifest(manifest),
|
||||
userValues: manifest.userValues || {},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Failed to get preset:", error);
|
||||
reply.status(500).send({ error: error.message || "Failed to get preset" });
|
||||
}
|
||||
});
|
||||
|
||||
// 上传并安装预设(支持文件上传)
|
||||
// Upload and install preset (supports file upload)
|
||||
app.post("/api/presets/install", async (req: any, reply: any) => {
|
||||
try {
|
||||
const { source, name, url } = req.body;
|
||||
|
||||
// 如果提供了 URL,从 URL 下载
|
||||
// If URL is provided, download from URL
|
||||
if (url) {
|
||||
const tempFile = await downloadPresetToTemp(url);
|
||||
const preset = await loadPresetFromZip(tempFile);
|
||||
|
||||
// 确定预设名称
|
||||
// Determine preset name
|
||||
const presetName = name || preset.metadata?.name || `preset-${Date.now()}`;
|
||||
|
||||
// 检查是否已安装
|
||||
// Check if already installed
|
||||
if (await isPresetInstalled(presetName)) {
|
||||
reply.status(409).send({ error: "Preset already installed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 解压到目标目录
|
||||
// Extract to target directory
|
||||
const targetDir = getPresetDir(presetName);
|
||||
await extractPreset(tempFile, targetDir);
|
||||
|
||||
// 清理临时文件
|
||||
// Clean up temp file
|
||||
unlinkSync(tempFile);
|
||||
|
||||
return {
|
||||
@@ -277,8 +284,8 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
};
|
||||
}
|
||||
|
||||
// 如果没有 URL,需要处理文件上传(使用 multipart/form-data)
|
||||
// 这部分需要在客户端使用 FormData 上传
|
||||
// If no URL, need to handle file upload (using multipart/form-data)
|
||||
// This part requires FormData upload on client side
|
||||
reply.status(400).send({ error: "Please provide a URL or upload a file" });
|
||||
} catch (error: any) {
|
||||
console.error("Failed to install preset:", error);
|
||||
@@ -286,7 +293,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 上传预设文件(multipart/form-data)
|
||||
// Upload preset file (multipart/form-data)
|
||||
app.post("/api/presets/upload", async (req: any, reply: any) => {
|
||||
try {
|
||||
const data = await req.file();
|
||||
@@ -300,28 +307,28 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
|
||||
const tempFile = join(tempDir, `preset-${Date.now()}.ccrsets`);
|
||||
|
||||
// 保存上传的文件到临时位置
|
||||
// Save uploaded file to temp location
|
||||
const buffer = await data.toBuffer();
|
||||
writeFileSync(tempFile, buffer);
|
||||
|
||||
// 加载预设
|
||||
// Load preset
|
||||
const preset = await loadPresetFromZip(tempFile);
|
||||
|
||||
// 确定预设名称
|
||||
// Determine preset name
|
||||
const presetName = data.fields.name?.value || preset.metadata?.name || `preset-${Date.now()}`;
|
||||
|
||||
// 检查是否已安装
|
||||
// Check if already installed
|
||||
if (await isPresetInstalled(presetName)) {
|
||||
unlinkSync(tempFile);
|
||||
reply.status(409).send({ error: "Preset already installed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 解压到目标目录
|
||||
// Extract to target directory
|
||||
const targetDir = getPresetDir(presetName);
|
||||
await extractPreset(tempFile, targetDir);
|
||||
|
||||
// 清理临时文件
|
||||
// Clean up temp file
|
||||
unlinkSync(tempFile);
|
||||
|
||||
return {
|
||||
@@ -338,7 +345,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 应用预设(配置敏感信息)
|
||||
// Apply preset (configure sensitive information)
|
||||
app.post("/api/presets/:name/apply", async (req: any, reply: any) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
@@ -351,27 +358,22 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取现有 manifest
|
||||
// Read existing manifest
|
||||
const manifest = await readManifestFromDir(presetDir);
|
||||
|
||||
// 将 secrets 信息应用到 manifest 中
|
||||
if (secrets) {
|
||||
for (const [fieldPath, value] of Object.entries(secrets)) {
|
||||
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;
|
||||
}
|
||||
// Save user input to userValues (keep original config unchanged)
|
||||
const updatedManifest: ManifestFile = { ...manifest };
|
||||
|
||||
// Save or update userValues
|
||||
if (secrets && Object.keys(secrets).length > 0) {
|
||||
updatedManifest.userValues = {
|
||||
...updatedManifest.userValues,
|
||||
...secrets,
|
||||
};
|
||||
}
|
||||
|
||||
// 保存更新后的 manifest
|
||||
await saveManifest(name, manifest);
|
||||
// Save updated manifest
|
||||
await saveManifest(name, updatedManifest);
|
||||
|
||||
return { success: true, message: "Preset applied successfully" };
|
||||
} catch (error: any) {
|
||||
@@ -380,7 +382,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 删除预设
|
||||
// Delete preset
|
||||
app.delete("/api/presets/:name", async (req: any, reply: any) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
@@ -391,7 +393,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 递归删除整个目录
|
||||
// Recursively delete entire directory
|
||||
rmSync(presetDir, { recursive: true, force: true });
|
||||
|
||||
return { success: true, message: "Preset deleted successfully" };
|
||||
@@ -401,7 +403,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取预设市场列表
|
||||
// Get preset market list
|
||||
app.get("/api/presets/market", async (req: any, reply: any) => {
|
||||
try {
|
||||
const marketUrl = "https://pub-0dc3e1677e894f07bbea11b17a29e032.r2.dev/presets.json";
|
||||
@@ -419,7 +421,7 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 从 GitHub 仓库安装预设
|
||||
// Install preset from GitHub repository
|
||||
app.post("/api/presets/install/github", async (req: any, reply: any) => {
|
||||
try {
|
||||
const { repo, name } = req.body;
|
||||
@@ -429,9 +431,9 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 GitHub 仓库 URL
|
||||
// 支持格式:
|
||||
// - owner/repo (简短格式,来自市场)
|
||||
// Parse GitHub repository URL
|
||||
// Supported formats:
|
||||
// - owner/repo (short format, from market)
|
||||
// - github.com/owner/repo
|
||||
// - https://github.com/owner/repo
|
||||
// - https://github.com/owner/repo.git
|
||||
@@ -444,28 +446,28 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
|
||||
const [, owner, repoName] = githubRepoMatch;
|
||||
|
||||
// 下载 GitHub 仓库的 ZIP 文件
|
||||
// Download GitHub repository ZIP file
|
||||
const downloadUrl = `https://github.com/${owner}/${repoName}/archive/refs/heads/main.zip`;
|
||||
const tempFile = await downloadPresetToTemp(downloadUrl);
|
||||
|
||||
// 加载预设
|
||||
// Load preset
|
||||
const preset = await loadPresetFromZip(tempFile);
|
||||
|
||||
// 确定预设名称
|
||||
// Determine preset name
|
||||
const presetName = name || preset.metadata?.name || repoName;
|
||||
|
||||
// 检查是否已安装
|
||||
// Check if already installed
|
||||
if (await isPresetInstalled(presetName)) {
|
||||
unlinkSync(tempFile);
|
||||
reply.status(409).send({ error: "Preset already installed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 解压到目标目录
|
||||
// Extract to target directory
|
||||
const targetDir = getPresetDir(presetName);
|
||||
await extractPreset(tempFile, targetDir);
|
||||
|
||||
// 清理临时文件
|
||||
// Clean up temp file
|
||||
unlinkSync(tempFile);
|
||||
|
||||
return {
|
||||
@@ -482,17 +484,17 @@ export const createServer = async (config: any): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 辅助函数:从 ZIP 加载预设
|
||||
// Helper function: Load preset from ZIP
|
||||
async function loadPresetFromZip(zipFile: string): Promise<PresetFile> {
|
||||
const zip = new AdmZip(zipFile);
|
||||
|
||||
// 首先尝试在根目录查找 manifest.json
|
||||
// First try to find manifest.json in root directory
|
||||
let entry = zip.getEntry('manifest.json');
|
||||
|
||||
// 如果根目录没有,尝试在子目录中查找(处理 GitHub 仓库的压缩包结构)
|
||||
// If not in root, try to find in subdirectories (handle GitHub repo archive structure)
|
||||
if (!entry) {
|
||||
const entries = zip.getEntries();
|
||||
// 查找任意 manifest.json 文件
|
||||
// Find any manifest.json file
|
||||
entry = entries.find(e => e.entryName.includes('manifest.json')) || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,77 @@
|
||||
/**
|
||||
* 预设安装核心功能
|
||||
* 注意:这个模块不包含 CLI 交互逻辑,交互逻辑由调用者提供
|
||||
* Core preset installation functionality
|
||||
* Note: This module does not contain CLI interaction logic, interaction logic is provided by the caller
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import JSON5 from 'json5';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { PresetFile, MergeStrategy, RequiredInput, ManifestFile, PresetInfo } from './types';
|
||||
import { PresetFile, MergeStrategy, RequiredInput, ManifestFile, PresetInfo, PresetMetadata } from './types';
|
||||
import { HOME_DIR, PRESETS_DIR } from '../constants';
|
||||
import { loadConfigFromManifest } from './schema';
|
||||
|
||||
/**
|
||||
* 获取预设目录的完整路径
|
||||
* @param presetName 预设名称
|
||||
* Validate if preset name is safe (prevent path traversal attacks)
|
||||
* @param presetName Preset name
|
||||
*/
|
||||
function validatePresetName(presetName: string): void {
|
||||
if (!presetName || presetName.trim() === '') {
|
||||
throw new Error('Preset name cannot be empty');
|
||||
}
|
||||
|
||||
// Reject names containing path traversal sequences
|
||||
if (presetName.includes('..') || presetName.includes('/') || presetName.includes('\\')) {
|
||||
throw new Error('Invalid preset name: path traversal detected');
|
||||
}
|
||||
|
||||
// Reject absolute paths
|
||||
if (path.isAbsolute(presetName)) {
|
||||
throw new Error('Invalid preset name: absolute path not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path of the preset directory
|
||||
* @param presetName Preset name
|
||||
*/
|
||||
export function getPresetDir(presetName: string): string {
|
||||
validatePresetName(presetName);
|
||||
return path.join(HOME_DIR, 'presets', presetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时目录路径
|
||||
* Get temporary directory path
|
||||
*/
|
||||
export function getTempDir(): string {
|
||||
return path.join(HOME_DIR, 'temp');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压预设文件到目标目录
|
||||
* @param sourceZip 源ZIP文件路径
|
||||
* @param targetDir 目标目录
|
||||
* Validate and normalize file path, ensuring it's within the target directory
|
||||
* @param targetDir Target directory
|
||||
* @param entryPath ZIP entry path
|
||||
* @returns Safe absolute path
|
||||
*/
|
||||
function validateAndResolvePath(targetDir: string, entryPath: string): string {
|
||||
const resolvedTargetDir = path.resolve(targetDir);
|
||||
const resolvedPath = path.resolve(targetDir, entryPath);
|
||||
|
||||
// Verify that the resolved path is within the target directory
|
||||
if (!resolvedPath.startsWith(resolvedTargetDir)) {
|
||||
throw new Error(`Path traversal detected: ${entryPath}`);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract preset file to target directory
|
||||
* @param sourceZip Source ZIP file path
|
||||
* @param targetDir Target directory
|
||||
*/
|
||||
export async function extractPreset(sourceZip: string, targetDir: string): Promise<void> {
|
||||
// 检查目标目录是否已存在
|
||||
// Check if target directory already exists
|
||||
try {
|
||||
await fs.access(targetDir);
|
||||
throw new Error(`Preset directory already exists: ${path.basename(targetDir)}`);
|
||||
@@ -39,19 +79,19 @@ export async function extractPreset(sourceZip: string, targetDir: string): Promi
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// ENOENT 表示目录不存在,可以继续
|
||||
// ENOENT means directory does not exist, can continue
|
||||
}
|
||||
|
||||
// 创建目标目录
|
||||
// Create target directory
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
// 解压文件
|
||||
// Extract files
|
||||
const zip = new AdmZip(sourceZip);
|
||||
const entries = zip.getEntries();
|
||||
|
||||
// 检测是否有单一的根目录(GitHub ZIP 文件通常有这个特征)
|
||||
// Detect if there's a single root directory (GitHub ZIP files usually have this characteristic)
|
||||
if (entries.length > 0) {
|
||||
// 获取所有顶层目录
|
||||
// Get all top-level directories
|
||||
const rootDirs = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const parts = entry.entryName.split('/');
|
||||
@@ -60,35 +100,35 @@ export async function extractPreset(sourceZip: string, targetDir: string): Promi
|
||||
}
|
||||
}
|
||||
|
||||
// 如果只有一个根目录,则去除它
|
||||
// If there's only one root directory, remove it
|
||||
if (rootDirs.size === 1) {
|
||||
const singleRoot = Array.from(rootDirs)[0];
|
||||
|
||||
// 检查 manifest.json 是否在根目录下
|
||||
// Check if manifest.json is in root directory
|
||||
const hasManifestInRoot = entries.some(e =>
|
||||
e.entryName === 'manifest.json' || e.entryName.startsWith(`${singleRoot}/manifest.json`)
|
||||
);
|
||||
|
||||
if (hasManifestInRoot) {
|
||||
// 将所有文件从根目录下提取出来
|
||||
// Extract all files from the root directory
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 去除根目录前缀
|
||||
// Remove root directory prefix
|
||||
let newPath = entry.entryName;
|
||||
if (newPath.startsWith(`${singleRoot}/`)) {
|
||||
newPath = newPath.substring(singleRoot.length + 1);
|
||||
}
|
||||
|
||||
// 跳过根目录本身
|
||||
// Skip root directory itself
|
||||
if (newPath === '' || newPath === singleRoot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取文件
|
||||
const targetPath = path.join(targetDir, newPath);
|
||||
// Validate path safety and extract file
|
||||
const targetPath = validateAndResolvePath(targetDir, newPath);
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, entry.getData());
|
||||
}
|
||||
@@ -98,13 +138,22 @@ export async function extractPreset(sourceZip: string, targetDir: string): Promi
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有单一的根目录,直接解压
|
||||
zip.extractAllTo(targetDir, true);
|
||||
// If there's no single root directory, validate and extract files one by one
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate path safety
|
||||
const targetPath = validateAndResolvePath(targetDir, entry.entryName);
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, entry.getData());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从解压目录读取manifest
|
||||
* @param presetDir 预设目录路径
|
||||
* Read manifest from extracted directory
|
||||
* @param presetDir Preset directory path
|
||||
*/
|
||||
export async function readManifestFromDir(presetDir: string): Promise<ManifestFile> {
|
||||
const manifestPath = path.join(presetDir, 'manifest.json');
|
||||
@@ -113,21 +162,68 @@ export async function readManifestFromDir(presetDir: string): Promise<ManifestFi
|
||||
}
|
||||
|
||||
/**
|
||||
* 将manifest转换为PresetFile格式
|
||||
* List of known metadata fields
|
||||
*/
|
||||
const METADATA_FIELDS = [
|
||||
'name',
|
||||
'version',
|
||||
'description',
|
||||
'author',
|
||||
'homepage',
|
||||
'repository',
|
||||
'license',
|
||||
'keywords',
|
||||
'ccrVersion',
|
||||
'source',
|
||||
'sourceType',
|
||||
'checksum',
|
||||
];
|
||||
|
||||
/**
|
||||
* Dynamic configuration system field list
|
||||
*/
|
||||
const DYNAMIC_CONFIG_FIELDS = [
|
||||
'schema',
|
||||
'template',
|
||||
'configMappings',
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert manifest to PresetFile format
|
||||
* Correctly separate metadata, config, and dynamic configuration system fields
|
||||
*/
|
||||
export function manifestToPresetFile(manifest: ManifestFile): PresetFile {
|
||||
const { Providers, Router, StatusLine, NON_INTERACTIVE_MODE, schema, ...metadata } = manifest;
|
||||
const metadata: any = {};
|
||||
const config: any = {};
|
||||
const dynamicConfig: any = {};
|
||||
|
||||
// Categorize all fields
|
||||
for (const [key, value] of Object.entries(manifest)) {
|
||||
if (METADATA_FIELDS.includes(key)) {
|
||||
// metadata fields
|
||||
metadata[key] = value;
|
||||
} else if (DYNAMIC_CONFIG_FIELDS.includes(key)) {
|
||||
// dynamic configuration system fields
|
||||
dynamicConfig[key] = value;
|
||||
} else {
|
||||
// configuration fields
|
||||
config[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metadata,
|
||||
config: { Providers, Router, StatusLine, NON_INTERACTIVE_MODE },
|
||||
schema,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
config,
|
||||
schema: dynamicConfig.schema,
|
||||
template: dynamicConfig.template,
|
||||
configMappings: dynamicConfig.configMappings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载预设文件到临时位置
|
||||
* @param url 下载URL
|
||||
* @returns 临时文件路径
|
||||
* Download preset file to temporary location
|
||||
* @param url Download URL
|
||||
* @returns Temporary file path
|
||||
*/
|
||||
export async function downloadPresetToTemp(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
@@ -136,7 +232,7 @@ export async function downloadPresetToTemp(url: string): Promise<string> {
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
// 创建临时文件
|
||||
// Create temporary file
|
||||
const tempDir = getTempDir();
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
@@ -147,8 +243,8 @@ export async function downloadPresetToTemp(url: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地ZIP文件加载预设
|
||||
* @param zipFile ZIP文件路径
|
||||
* Load preset from local ZIP file
|
||||
* @param zipFile ZIP file path
|
||||
* @returns PresetFile
|
||||
*/
|
||||
export async function loadPresetFromZip(zipFile: string): Promise<PresetFile> {
|
||||
@@ -162,33 +258,33 @@ export async function loadPresetFromZip(zipFile: string): Promise<PresetFile> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载预设文件
|
||||
* @param source 预设来源(文件路径、URL 或预设名称)
|
||||
* Load preset file
|
||||
* @param source Preset source (file path, URL, or preset name)
|
||||
*/
|
||||
export async function loadPreset(source: string): Promise<PresetFile> {
|
||||
// 判断是否是 URL
|
||||
// Check if it's a URL
|
||||
if (source.startsWith('http://') || source.startsWith('https://')) {
|
||||
const tempFile = await downloadPresetToTemp(source);
|
||||
const preset = await loadPresetFromZip(tempFile);
|
||||
// 删除临时文件
|
||||
// Delete temp file
|
||||
await fs.unlink(tempFile).catch(() => {});
|
||||
return preset;
|
||||
}
|
||||
|
||||
// 判断是否是绝对路径或相对路径(包含 / 或 \)
|
||||
// Check if it's absolute or relative path (contains / or \)
|
||||
if (source.includes('/') || source.includes('\\')) {
|
||||
// 文件路径
|
||||
// File path
|
||||
return await loadPresetFromZip(source);
|
||||
}
|
||||
|
||||
// 否则作为预设名称处理(从解压目录读取)
|
||||
// Otherwise treat as preset name (read from extracted directory)
|
||||
const presetDir = getPresetDir(source);
|
||||
const manifest = await readManifestFromDir(presetDir);
|
||||
return manifestToPresetFile(manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证预设文件
|
||||
* Validate preset file
|
||||
*/
|
||||
export async function validatePreset(preset: PresetFile): Promise<{
|
||||
valid: boolean;
|
||||
@@ -198,7 +294,7 @@ export async function validatePreset(preset: PresetFile): Promise<{
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 验证元数据
|
||||
// Validate metadata
|
||||
if (!preset.metadata) {
|
||||
warnings.push('Missing metadata section');
|
||||
} else {
|
||||
@@ -210,12 +306,12 @@ export async function validatePreset(preset: PresetFile): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// 验证配置部分
|
||||
// Validate configuration section
|
||||
if (!preset.config) {
|
||||
errors.push('Missing config section');
|
||||
}
|
||||
|
||||
// 验证 Providers
|
||||
// Validate Providers
|
||||
if (preset.config.Providers) {
|
||||
for (const provider of preset.config.Providers) {
|
||||
if (!provider.name) {
|
||||
@@ -238,9 +334,35 @@ export async function validatePreset(preset: PresetFile): Promise<{
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 manifest 到预设目录
|
||||
* @param presetName 预设名称
|
||||
* @param manifest manifest 对象
|
||||
* Extract metadata fields from manifest
|
||||
* @param manifest Manifest object
|
||||
* @returns Metadata object
|
||||
*/
|
||||
export function extractMetadata(manifest: ManifestFile): PresetMetadata {
|
||||
const metadata: PresetMetadata = {
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
};
|
||||
|
||||
// Optional fields
|
||||
if (manifest.description !== undefined) metadata.description = manifest.description;
|
||||
if (manifest.author !== undefined) metadata.author = manifest.author;
|
||||
if (manifest.homepage !== undefined) metadata.homepage = manifest.homepage;
|
||||
if (manifest.repository !== undefined) metadata.repository = manifest.repository;
|
||||
if (manifest.license !== undefined) metadata.license = manifest.license;
|
||||
if (manifest.keywords !== undefined) metadata.keywords = manifest.keywords;
|
||||
if (manifest.ccrVersion !== undefined) metadata.ccrVersion = manifest.ccrVersion;
|
||||
if (manifest.source !== undefined) metadata.source = manifest.source;
|
||||
if (manifest.sourceType !== undefined) metadata.sourceType = manifest.sourceType;
|
||||
if (manifest.checksum !== undefined) metadata.checksum = manifest.checksum;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save manifest to preset directory
|
||||
* @param presetName Preset name
|
||||
* @param manifest Manifest object
|
||||
*/
|
||||
export async function saveManifest(presetName: string, manifest: ManifestFile): Promise<void> {
|
||||
const presetDir = getPresetDir(presetName);
|
||||
@@ -249,23 +371,23 @@ export async function saveManifest(presetName: string, manifest: ManifestFile):
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找预设文件
|
||||
* @param source 预设来源
|
||||
* @returns 文件路径或 null
|
||||
* Find preset file
|
||||
* @param source Preset source
|
||||
* @returns File path or null
|
||||
*/
|
||||
export async function findPresetFile(source: string): Promise<string | null> {
|
||||
// 当前目录文件
|
||||
// Current directory file
|
||||
const currentDirFile = path.join(process.cwd(), `${source}.ccrsets`);
|
||||
|
||||
// presets 目录文件
|
||||
// presets directory file
|
||||
const presetsDirFile = path.join(HOME_DIR, 'presets', `${source}.ccrsets`);
|
||||
|
||||
// 检查当前目录
|
||||
// Check current directory
|
||||
try {
|
||||
await fs.access(currentDirFile);
|
||||
return currentDirFile;
|
||||
} catch {
|
||||
// 检查presets目录
|
||||
// Check presets directory
|
||||
try {
|
||||
await fs.access(presetsDirFile);
|
||||
return presetsDirFile;
|
||||
@@ -276,8 +398,8 @@ export async function findPresetFile(source: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查预设是否已安装
|
||||
* @param presetName 预设名称
|
||||
* Check if preset is already installed
|
||||
* @param presetName Preset name
|
||||
*/
|
||||
export async function isPresetInstalled(presetName: string): Promise<boolean> {
|
||||
const presetDir = getPresetDir(presetName);
|
||||
@@ -290,8 +412,8 @@ export async function isPresetInstalled(presetName: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有已安装的预设
|
||||
* @returns PresetInfo 数组
|
||||
* List all installed presets
|
||||
* @returns Array of PresetInfo
|
||||
*/
|
||||
export async function listPresets(): Promise<PresetInfo[]> {
|
||||
const presetsDir = PRESETS_DIR;
|
||||
@@ -303,7 +425,7 @@ export async function listPresets(): Promise<PresetInfo[]> {
|
||||
return presets;
|
||||
}
|
||||
|
||||
// 读取目录下的所有子目录
|
||||
// Read all subdirectories in the directory
|
||||
const entries = await fs.readdir(presetsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -313,14 +435,14 @@ export async function listPresets(): Promise<PresetInfo[]> {
|
||||
const manifestPath = path.join(presetDir, 'manifest.json');
|
||||
|
||||
try {
|
||||
// 检查 manifest.json 是否存在
|
||||
// Check if manifest.json exists
|
||||
await fs.access(manifestPath);
|
||||
|
||||
// 读取 manifest.json
|
||||
// Read manifest.json
|
||||
const content = await fs.readFile(manifestPath, 'utf-8');
|
||||
const manifest = JSON5.parse(content) as ManifestFile;
|
||||
|
||||
// 获取目录创建时间
|
||||
// Get directory creation time
|
||||
const stats = await fs.stat(presetDir);
|
||||
|
||||
presets.push({
|
||||
@@ -328,11 +450,11 @@ export async function listPresets(): Promise<PresetInfo[]> {
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
config: manifestToPresetFile(manifest).config,
|
||||
config: loadConfigFromManifest(manifest),
|
||||
});
|
||||
} catch {
|
||||
// 忽略无效的预设目录(没有 manifest.json 或读取失败)
|
||||
// 可以选择跳过或者添加到列表中标记为错误
|
||||
// Ignore invalid preset directories (no manifest.json or read failed)
|
||||
// Can choose to skip or add to list marked as error
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* 读取预设配置文件
|
||||
* 用于 CLI 快速读取预设配置
|
||||
* Read preset configuration file
|
||||
* Used by CLI to quickly read preset configuration
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import JSON5 from 'json5';
|
||||
import { HOME_DIR } from '../constants';
|
||||
import { getPresetDir } from './install';
|
||||
|
||||
/**
|
||||
* 读取 preset 配置文件
|
||||
* @param name preset 名称
|
||||
* @returns preset 配置对象,如果文件不存在则返回 null
|
||||
* Read preset configuration file
|
||||
* @param name Preset name
|
||||
* @returns Preset configuration object, or null if file does not exist
|
||||
*/
|
||||
export async function readPresetFile(name: string): Promise<any | null> {
|
||||
try {
|
||||
const presetDir = path.join(HOME_DIR, 'presets', name);
|
||||
const presetDir = getPresetDir(name);
|
||||
const manifestPath = path.join(presetDir, 'manifest.json');
|
||||
const manifest = JSON5.parse(await fs.readFile(manifestPath, 'utf-8'));
|
||||
// manifest已经是扁平化结构,直接返回
|
||||
// manifest is already a flat structure, return directly
|
||||
return manifest;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 动态配置 Schema 处理器
|
||||
* 负责解析和验证配置 schema,处理条件逻辑和变量替换
|
||||
* Dynamic configuration Schema handler
|
||||
* Responsible for parsing and validating configuration schema, handling conditional logic and variable replacement
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -12,16 +12,14 @@ import {
|
||||
ConfigMapping,
|
||||
TemplateConfig,
|
||||
PresetConfigSection,
|
||||
PresetFile,
|
||||
ManifestFile,
|
||||
UserInputValues,
|
||||
} from './types';
|
||||
|
||||
// 用户输入值集合
|
||||
export interface UserInputValues {
|
||||
[inputId: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析字段路径(支持数组和嵌套)
|
||||
* 例如:Providers[0].name => ['Providers', '0', 'name']
|
||||
* Parse field path (supports arrays and nesting)
|
||||
* Example: Providers[0].name => ['Providers', '0', 'name']
|
||||
*/
|
||||
export function parseFieldPath(path: string): string[] {
|
||||
const regex = /(\w+)|\[(\d+)\]/g;
|
||||
@@ -36,7 +34,7 @@ export function parseFieldPath(path: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字段路径获取对象中的值
|
||||
* Get value from object by field path
|
||||
*/
|
||||
export function getValueByPath(obj: any, path: string): any {
|
||||
const parts = parseFieldPath(path);
|
||||
@@ -53,7 +51,7 @@ export function getValueByPath(obj: any, path: string): any {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字段路径设置对象中的值
|
||||
* Set value in object by field path
|
||||
*/
|
||||
export function setValueByPath(obj: any, path: string, value: any): void {
|
||||
const parts = parseFieldPath(path);
|
||||
@@ -62,7 +60,7 @@ export function setValueByPath(obj: any, path: string, value: any): void {
|
||||
|
||||
for (const part of parts) {
|
||||
if (!(part in current)) {
|
||||
// 判断是数组还是对象
|
||||
// Determine if it's an array or object
|
||||
const nextPart = parts[parts.indexOf(part) + 1];
|
||||
if (nextPart && /^\d+$/.test(nextPart)) {
|
||||
current[part] = [];
|
||||
@@ -77,7 +75,7 @@ export function setValueByPath(obj: any, path: string, value: any): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估条件表达式
|
||||
* Evaluate conditional expression
|
||||
*/
|
||||
export function evaluateCondition(
|
||||
condition: Condition,
|
||||
@@ -85,22 +83,22 @@ export function evaluateCondition(
|
||||
): boolean {
|
||||
const actualValue = values[condition.field];
|
||||
|
||||
// 处理 exists 操作符
|
||||
// Handle exists operator
|
||||
if (condition.operator === 'exists') {
|
||||
return actualValue !== undefined && actualValue !== null;
|
||||
}
|
||||
|
||||
// 处理 in 操作符
|
||||
// Handle in operator
|
||||
if (condition.operator === 'in') {
|
||||
return Array.isArray(condition.value) && condition.value.includes(actualValue);
|
||||
}
|
||||
|
||||
// 处理 nin 操作符
|
||||
// Handle nin operator
|
||||
if (condition.operator === 'nin') {
|
||||
return Array.isArray(condition.value) && !condition.value.includes(actualValue);
|
||||
}
|
||||
|
||||
// 处理其他操作符
|
||||
// Handle other operators
|
||||
switch (condition.operator) {
|
||||
case 'eq':
|
||||
return actualValue === condition.value;
|
||||
@@ -115,13 +113,13 @@ export function evaluateCondition(
|
||||
case 'lte':
|
||||
return actualValue <= condition.value;
|
||||
default:
|
||||
// 默认使用 eq
|
||||
// Default to eq
|
||||
return actualValue === condition.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估多个条件(AND 逻辑)
|
||||
* Evaluate multiple conditions (AND logic)
|
||||
*/
|
||||
export function evaluateConditions(
|
||||
conditions: Condition | Condition[],
|
||||
@@ -135,12 +133,12 @@ export function evaluateConditions(
|
||||
return evaluateCondition(conditions, values);
|
||||
}
|
||||
|
||||
// 如果是数组,使用 AND 逻辑(所有条件都必须满足)
|
||||
// If array, use AND logic (all conditions must be satisfied)
|
||||
return conditions.every(condition => evaluateCondition(condition, values));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否应该显示
|
||||
* Determine if field should be displayed
|
||||
*/
|
||||
export function shouldShowField(
|
||||
field: RequiredInput,
|
||||
@@ -154,7 +152,7 @@ export function shouldShowField(
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动态选项列表
|
||||
* Get dynamic options list
|
||||
*/
|
||||
export function getDynamicOptions(
|
||||
dynamicOptions: DynamicOptions,
|
||||
@@ -166,7 +164,7 @@ export function getDynamicOptions(
|
||||
return dynamicOptions.options || [];
|
||||
|
||||
case 'providers': {
|
||||
// 从预设的 Providers 中提取选项
|
||||
// Extract options from preset's Providers
|
||||
const providers = presetConfig.Providers || [];
|
||||
return providers.map((p: any) => ({
|
||||
label: p.name || p.id || String(p),
|
||||
@@ -176,13 +174,13 @@ export function getDynamicOptions(
|
||||
}
|
||||
|
||||
case 'models': {
|
||||
// 从指定 provider 的 models 中提取
|
||||
// Extract from specified provider's models
|
||||
const providerField = dynamicOptions.providerField;
|
||||
if (!providerField) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 解析 provider 引用(如 {{selectedProvider}})
|
||||
// Parse provider reference (e.g. {{selectedProvider}})
|
||||
const providerId = String(providerField).replace(/^{{(.+)}}$/, '$1');
|
||||
const selectedProvider = values[providerId];
|
||||
|
||||
@@ -190,7 +188,7 @@ export function getDynamicOptions(
|
||||
return [];
|
||||
}
|
||||
|
||||
// 查找对应的 provider
|
||||
// Find corresponding provider
|
||||
const provider = presetConfig.Providers.find(
|
||||
(p: any) => p.name === selectedProvider || p.id === selectedProvider
|
||||
);
|
||||
@@ -206,7 +204,7 @@ export function getDynamicOptions(
|
||||
}
|
||||
|
||||
case 'custom':
|
||||
// 预留,暂未实现
|
||||
// Reserved, not implemented yet
|
||||
return [];
|
||||
|
||||
default:
|
||||
@@ -215,7 +213,7 @@ export function getDynamicOptions(
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析选项(支持静态和动态选项)
|
||||
* Resolve options (supports static and dynamic options)
|
||||
*/
|
||||
export function resolveOptions(
|
||||
field: RequiredInput,
|
||||
@@ -226,16 +224,16 @@ export function resolveOptions(
|
||||
return [];
|
||||
}
|
||||
|
||||
// 判断是静态选项还是动态选项
|
||||
// Determine if static or dynamic options
|
||||
const options = field.options as any;
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
// 静态选项数组
|
||||
// Static options array
|
||||
return options as InputOption[];
|
||||
}
|
||||
|
||||
if (options.type) {
|
||||
// 动态选项
|
||||
// Dynamic options
|
||||
return getDynamicOptions(options, presetConfig, values);
|
||||
}
|
||||
|
||||
@@ -243,8 +241,8 @@ export function resolveOptions(
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板变量替换
|
||||
* 支持 {{variable}} 语法
|
||||
* Template variable replacement
|
||||
* Supports {{variable}} syntax
|
||||
*/
|
||||
export function replaceTemplateVariables(
|
||||
template: any,
|
||||
@@ -254,19 +252,19 @@ export function replaceTemplateVariables(
|
||||
return template;
|
||||
}
|
||||
|
||||
// 处理字符串
|
||||
// Handle strings
|
||||
if (typeof template === 'string') {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||
return values[key] !== undefined ? String(values[key]) : '';
|
||||
});
|
||||
}
|
||||
|
||||
// 处理数组
|
||||
// Handle arrays
|
||||
if (Array.isArray(template)) {
|
||||
return template.map(item => replaceTemplateVariables(item, values));
|
||||
}
|
||||
|
||||
// 处理对象
|
||||
// Handle objects
|
||||
if (typeof template === 'object') {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
@@ -275,12 +273,12 @@ export function replaceTemplateVariables(
|
||||
return result;
|
||||
}
|
||||
|
||||
// 其他类型直接返回
|
||||
// Return other types directly
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用配置映射
|
||||
* Apply configuration mappings
|
||||
*/
|
||||
export function applyConfigMappings(
|
||||
mappings: ConfigMapping[],
|
||||
@@ -290,23 +288,23 @@ export function applyConfigMappings(
|
||||
const result = { ...config };
|
||||
|
||||
for (const mapping of mappings) {
|
||||
// 检查条件
|
||||
// Check condition
|
||||
if (mapping.when && !evaluateConditions(mapping.when, values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析值
|
||||
// Resolve value
|
||||
let value: any;
|
||||
if (typeof mapping.value === 'string' && mapping.value.startsWith('{{')) {
|
||||
// 变量引用
|
||||
// Variable reference
|
||||
const varName = mapping.value.replace(/^{{(.+)}}$/, '$1');
|
||||
value = values[varName];
|
||||
} else {
|
||||
// 固定值
|
||||
// Fixed value
|
||||
value = mapping.value;
|
||||
}
|
||||
|
||||
// 应用到目标路径
|
||||
// Apply to target path
|
||||
setValueByPath(result, mapping.target, value);
|
||||
}
|
||||
|
||||
@@ -314,13 +312,74 @@ export function applyConfigMappings(
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户输入
|
||||
* Get all field ids defined in schema
|
||||
*/
|
||||
function getSchemaFields(schema?: RequiredInput[]): Set<string> {
|
||||
if (!schema) return new Set();
|
||||
return new Set(schema.map(field => field.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply user inputs to preset configuration
|
||||
* This is the core function of the preset configuration system, uniformly handling
|
||||
* configuration application for both CLI and UI layers
|
||||
*
|
||||
* @param presetFile Preset file object
|
||||
* @param values User input values (schema id -> value)
|
||||
* @returns Applied configuration object
|
||||
*/
|
||||
export function applyUserInputs(
|
||||
presetFile: PresetFile,
|
||||
values: UserInputValues
|
||||
): PresetConfigSection {
|
||||
let config: PresetConfigSection = {};
|
||||
|
||||
// Get field ids defined in schema, for subsequent filtering
|
||||
const schemaFields = getSchemaFields(presetFile.schema);
|
||||
|
||||
// 1. First apply template (if exists)
|
||||
// template completely defines configuration structure, using {{variable}} placeholders
|
||||
if (presetFile.template) {
|
||||
config = replaceTemplateVariables(presetFile.template, values) as any;
|
||||
} else {
|
||||
// If no template, start from preset's existing config
|
||||
// Keep all fields, including schema's id fields (because they may contain placeholders)
|
||||
// These fields will be updated or replaced in subsequent configMappings
|
||||
config = presetFile.config ? { ...presetFile.config } : {};
|
||||
|
||||
// Replace placeholders in config (e.g. {{apiKey}} -> actual value)
|
||||
config = replaceTemplateVariables(config, values) as any;
|
||||
|
||||
// Finally, remove schema id fields (they should not appear in final configuration)
|
||||
for (const schemaField of schemaFields) {
|
||||
delete config[schemaField];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Then apply configMappings (if exists)
|
||||
// Map user inputs to specific configuration paths
|
||||
if (presetFile.configMappings && presetFile.configMappings.length > 0) {
|
||||
config = applyConfigMappings(presetFile.configMappings, values, config);
|
||||
}
|
||||
|
||||
// 3. Compatible with legacy: apply to keys containing paths (e.g. "Providers[0].api_key")
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (key.includes('.') || key.includes('[')) {
|
||||
setValueByPath(config, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user input
|
||||
*/
|
||||
export function validateInput(
|
||||
field: RequiredInput,
|
||||
value: any
|
||||
): { valid: boolean; error?: string } {
|
||||
// 检查必填
|
||||
// Check required
|
||||
if (field.required !== false && (value === undefined || value === null || value === '')) {
|
||||
return {
|
||||
valid: false,
|
||||
@@ -328,12 +387,12 @@ export function validateInput(
|
||||
};
|
||||
}
|
||||
|
||||
// 如果值为空且非必填,跳过验证
|
||||
// If value is empty and not required, skip validation
|
||||
if (!value && field.required === false) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// 类型检查
|
||||
// Type check
|
||||
switch (field.type) {
|
||||
case InputType.NUMBER:
|
||||
if (isNaN(Number(value))) {
|
||||
@@ -359,12 +418,12 @@ export function validateInput(
|
||||
|
||||
case InputType.SELECT:
|
||||
case InputType.MULTISELECT:
|
||||
// 检查值是否在选项中
|
||||
// 这里暂时跳过,因为需要动态获取选项
|
||||
// Check if value is in options
|
||||
// Skip here for now, as options need to be dynamically retrieved
|
||||
break;
|
||||
}
|
||||
|
||||
// 自定义验证器
|
||||
// Custom validator
|
||||
if (field.validator) {
|
||||
if (field.validator instanceof RegExp) {
|
||||
if (!field.validator.test(String(value))) {
|
||||
@@ -401,14 +460,14 @@ export function validateInput(
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段的默认值
|
||||
* Get field default value
|
||||
*/
|
||||
export function getDefaultValue(field: RequiredInput): any {
|
||||
if (field.defaultValue !== undefined) {
|
||||
return field.defaultValue;
|
||||
}
|
||||
|
||||
// 根据类型返回默认值
|
||||
// Return default value based on type
|
||||
switch (field.type) {
|
||||
case InputType.CONFIRM:
|
||||
return false;
|
||||
@@ -422,8 +481,8 @@ export function getDefaultValue(field: RequiredInput): any {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据依赖关系排序字段
|
||||
* 确保被依赖的字段排在前面
|
||||
* Sort fields by dependency
|
||||
* Ensure dependent fields are arranged first
|
||||
*/
|
||||
export function sortFieldsByDependencies(
|
||||
fields: RequiredInput[]
|
||||
@@ -438,7 +497,7 @@ export function sortFieldsByDependencies(
|
||||
|
||||
visited.add(field.id);
|
||||
|
||||
// 先处理依赖的字段
|
||||
// First handle dependent fields
|
||||
const dependencies = field.dependsOn || [];
|
||||
for (const depId of dependencies) {
|
||||
const depField = fields.find(f => f.id === depId);
|
||||
@@ -447,7 +506,7 @@ export function sortFieldsByDependencies(
|
||||
}
|
||||
}
|
||||
|
||||
// 从 when 条件中提取依赖
|
||||
// Extract dependencies from when conditions
|
||||
if (field.when) {
|
||||
const conditions = Array.isArray(field.when) ? field.when : [field.when];
|
||||
for (const cond of conditions) {
|
||||
@@ -469,7 +528,7 @@ export function sortFieldsByDependencies(
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建字段依赖图(用于优化更新顺序)
|
||||
* Build field dependency graph (for optimizing update order)
|
||||
*/
|
||||
export function buildDependencyGraph(
|
||||
fields: RequiredInput[]
|
||||
@@ -479,14 +538,14 @@ export function buildDependencyGraph(
|
||||
for (const field of fields) {
|
||||
const deps = new Set<string>();
|
||||
|
||||
// 从 dependsOn 提取依赖
|
||||
// Extract from dependsOn
|
||||
if (field.dependsOn) {
|
||||
for (const dep of field.dependsOn) {
|
||||
deps.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
// 从 when 条件提取依赖
|
||||
// Extract dependencies from when conditions
|
||||
if (field.when) {
|
||||
const conditions = Array.isArray(field.when) ? field.when : [field.when];
|
||||
for (const cond of conditions) {
|
||||
@@ -494,7 +553,7 @@ export function buildDependencyGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// 从动态选项提取依赖
|
||||
// Extract dependencies from dynamic options
|
||||
if (field.options) {
|
||||
const options = field.options as any;
|
||||
if (options.type === 'models' && options.providerField) {
|
||||
@@ -510,7 +569,7 @@ export function buildDependencyGraph(
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取受影响字段(当某个字段值变化时,哪些字段需要重新计算)
|
||||
* Get affected fields (when a field value changes, which fields need to be recalculated)
|
||||
*/
|
||||
export function getAffectedFields(
|
||||
changedFieldId: string,
|
||||
@@ -519,7 +578,7 @@ export function getAffectedFields(
|
||||
const affected = new Set<string>();
|
||||
const graph = buildDependencyGraph(fields);
|
||||
|
||||
// 找出所有依赖于 changedFieldId 的字段
|
||||
// Find all fields that depend on changedFieldId
|
||||
for (const [fieldId, deps] of graph.entries()) {
|
||||
if (deps.has(changedFieldId)) {
|
||||
affected.add(fieldId);
|
||||
@@ -528,3 +587,55 @@ export function getAffectedFields(
|
||||
|
||||
return affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from Manifest and apply userValues
|
||||
* Used when reading installed presets, applying user configuration values at runtime
|
||||
*
|
||||
* @param manifest Manifest object (contains original configuration and userValues)
|
||||
* @returns Applied configuration object
|
||||
*/
|
||||
export function loadConfigFromManifest(manifest: ManifestFile): PresetConfigSection {
|
||||
// Convert manifest to PresetFile format
|
||||
const presetFile: PresetFile = {
|
||||
metadata: {
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
homepage: manifest.homepage,
|
||||
repository: manifest.repository,
|
||||
license: manifest.license,
|
||||
keywords: manifest.keywords,
|
||||
ccrVersion: manifest.ccrVersion,
|
||||
source: manifest.source,
|
||||
sourceType: manifest.sourceType,
|
||||
checksum: manifest.checksum,
|
||||
},
|
||||
config: {},
|
||||
schema: manifest.schema,
|
||||
template: manifest.template,
|
||||
configMappings: manifest.configMappings,
|
||||
};
|
||||
|
||||
// Extract configuration section from manifest (exclude metadata and dynamic configuration fields)
|
||||
const METADATA_FIELDS = [
|
||||
'name', 'version', 'description', 'author', 'homepage', 'repository',
|
||||
'license', 'keywords', 'ccrVersion', 'source', 'sourceType', 'checksum',
|
||||
];
|
||||
const DYNAMIC_CONFIG_FIELDS = ['schema', 'template', 'configMappings', 'userValues'];
|
||||
|
||||
for (const [key, value] of Object.entries(manifest)) {
|
||||
if (!METADATA_FIELDS.includes(key) && !DYNAMIC_CONFIG_FIELDS.includes(key)) {
|
||||
presetFile.config[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// If userValues exist, apply them
|
||||
if (manifest.userValues && Object.keys(manifest.userValues).length > 0) {
|
||||
return applyUserInputs(presetFile, manifest.userValues);
|
||||
}
|
||||
|
||||
// If no userValues, return original configuration directly
|
||||
return presetFile.config;
|
||||
}
|
||||
|
||||
@@ -1,95 +1,100 @@
|
||||
/**
|
||||
* 预设功能的类型定义
|
||||
* Type definitions for preset functionality
|
||||
*/
|
||||
|
||||
// 输入类型枚举
|
||||
// Collection of user input values
|
||||
export interface UserInputValues {
|
||||
[inputId: string]: any;
|
||||
}
|
||||
|
||||
// Input type enumeration
|
||||
export enum InputType {
|
||||
PASSWORD = 'password', // 密码输入(隐藏)
|
||||
INPUT = 'input', // 文本输入
|
||||
SELECT = 'select', // 单选
|
||||
MULTISELECT = 'multiselect', // 多选
|
||||
CONFIRM = 'confirm', // 确认框
|
||||
EDITOR = 'editor', // 多行文本编辑器
|
||||
NUMBER = 'number', // 数字输入
|
||||
PASSWORD = 'password', // Password input (hidden)
|
||||
INPUT = 'input', // Text input
|
||||
SELECT = 'select', // Single selection
|
||||
MULTISELECT = 'multiselect', // Multiple selection
|
||||
CONFIRM = 'confirm', // Confirmation checkbox
|
||||
EDITOR = 'editor', // Multi-line text editor
|
||||
NUMBER = 'number', // Number input
|
||||
}
|
||||
|
||||
// 选项定义
|
||||
// Option definition
|
||||
export interface InputOption {
|
||||
label: string; // 显示文本
|
||||
value: string | number | boolean; // 实际值
|
||||
description?: string; // 选项描述
|
||||
disabled?: boolean; // 是否禁用
|
||||
icon?: string; // 图标
|
||||
label: string; // Display text
|
||||
value: string | number | boolean; // Actual value
|
||||
description?: string; // Option description
|
||||
disabled?: boolean; // Whether disabled
|
||||
icon?: string; // Icon
|
||||
}
|
||||
|
||||
// 动态选项源
|
||||
// Dynamic option source
|
||||
export interface DynamicOptions {
|
||||
type: 'static' | 'providers' | 'models' | 'custom';
|
||||
// static: 使用固定的 options 数组
|
||||
// providers: 从 Providers 配置中动态获取
|
||||
// models: 从指定 provider 的 models 中获取
|
||||
// custom: 自定义函数(暂未实现,预留)
|
||||
// static: Use fixed options array
|
||||
// providers: Dynamically retrieve from Providers configuration
|
||||
// models: Retrieve from specified provider's models
|
||||
// custom: Custom function (reserved, not implemented yet)
|
||||
|
||||
// 当 type 为 'static' 时使用
|
||||
// Used when type is 'static'
|
||||
options?: InputOption[];
|
||||
|
||||
// 当 type 为 'providers' 时使用
|
||||
// 自动从预设的 Providers 中提取 name 和相关配置
|
||||
// Used when type is 'providers'
|
||||
// Automatically extract name and related configuration from preset's Providers
|
||||
|
||||
// 当 type 为 'models' 时使用
|
||||
providerField?: string; // 指向 provider 选择器的字段路径(如 "{{selectedProvider}}")
|
||||
// Used when type is 'models'
|
||||
providerField?: string; // Point to provider selector field path (e.g. "{{selectedProvider}}")
|
||||
|
||||
// 当 type 为 'custom' 时使用(预留)
|
||||
source?: string; // 自定义数据源
|
||||
// Used when type is 'custom' (reserved)
|
||||
source?: string; // Custom data source
|
||||
}
|
||||
|
||||
// 条件表达式
|
||||
// Conditional expression
|
||||
export interface Condition {
|
||||
field: string; // 依赖的字段路径
|
||||
field: string; // Dependent field path
|
||||
operator?: 'eq' | 'ne' | 'in' | 'nin' | 'gt' | 'lt' | 'gte' | 'lte' | 'exists';
|
||||
value?: any; // 比较值
|
||||
// eq: 等于
|
||||
// ne: 不等于
|
||||
// in: 包含于(数组)
|
||||
// nin: 不包含于(数组)
|
||||
// gt: 大于
|
||||
// lt: 小于
|
||||
// gte: 大于等于
|
||||
// lte: 小于等于
|
||||
// exists: 字段存在(不检查值)
|
||||
value?: any; // Comparison value
|
||||
// eq: equals
|
||||
// ne: not equals
|
||||
// in: included in (array)
|
||||
// nin: not included in (array)
|
||||
// gt: greater than
|
||||
// lt: less than
|
||||
// gte: greater than or equal to
|
||||
// lte: less than or equal to
|
||||
// exists: field exists (doesn't check value)
|
||||
}
|
||||
|
||||
// 复杂的字段输入配置
|
||||
// Complex field input configuration
|
||||
export interface RequiredInput {
|
||||
id: string; // 唯一标识符(用于变量引用)
|
||||
type?: InputType; // 输入类型,默认为 password
|
||||
label?: string; // 显示标签
|
||||
prompt?: string; // 提示信息/描述
|
||||
placeholder?: string; // 占位符
|
||||
id: string; // Unique identifier (for variable reference)
|
||||
type?: InputType; // Input type, defaults to password
|
||||
label?: string; // Display label
|
||||
prompt?: string; // Prompt information/description
|
||||
placeholder?: string; // Placeholder
|
||||
|
||||
// 选项配置(用于 select/multiselect)
|
||||
// Option configuration (for select/multiselect)
|
||||
options?: InputOption[] | DynamicOptions;
|
||||
|
||||
// 条件显示
|
||||
when?: Condition | Condition[]; // 满足条件时才显示此字段(支持 AND/OR 逻辑)
|
||||
// Conditional display
|
||||
when?: Condition | Condition[]; // Show this field only when conditions are met (supports AND/OR logic)
|
||||
|
||||
// 默认值
|
||||
// Default value
|
||||
defaultValue?: any;
|
||||
|
||||
// 验证规则
|
||||
required?: boolean; // 是否必填,默认 true
|
||||
// Validation rules
|
||||
required?: boolean; // Whether required, defaults to true
|
||||
validator?: RegExp | string | ((value: any) => boolean | string);
|
||||
|
||||
// UI 配置
|
||||
min?: number; // 最小值(用于 number)
|
||||
max?: number; // 最大值(用于 number)
|
||||
rows?: number; // 行数(用于 editor)
|
||||
// UI configuration
|
||||
min?: number; // Minimum value (for number)
|
||||
max?: number; // Maximum value (for number)
|
||||
rows?: number; // Number of rows (for editor)
|
||||
|
||||
// 高级配置
|
||||
dependsOn?: string[]; // 显式声明依赖的字段(用于优化更新顺序)
|
||||
// Advanced configuration
|
||||
dependsOn?: string[]; // Explicitly declare dependent fields (for optimizing update order)
|
||||
}
|
||||
|
||||
// Provider 配置
|
||||
// Provider configuration
|
||||
export interface ProviderConfig {
|
||||
name: string;
|
||||
api_base_url: string;
|
||||
@@ -99,7 +104,7 @@ export interface ProviderConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Router 配置
|
||||
// Router configuration
|
||||
export interface RouterConfig {
|
||||
default?: string;
|
||||
background?: string;
|
||||
@@ -111,7 +116,7 @@ export interface RouterConfig {
|
||||
[key: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
// Transformer 配置
|
||||
// Transformer configuration
|
||||
export interface TransformerConfig {
|
||||
path?: string;
|
||||
use: Array<string | [string, any]>;
|
||||
@@ -119,23 +124,23 @@ export interface TransformerConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 预设元数据(扁平化结构,用于manifest.json)
|
||||
// Preset metadata (flattened structure, for manifest.json)
|
||||
export interface PresetMetadata {
|
||||
name: string; // 预设名称
|
||||
version: string; // 版本号 (semver)
|
||||
description?: string; // 描述
|
||||
author?: string; // 作者
|
||||
homepage?: string; // 主页
|
||||
repository?: string; // 源码仓库
|
||||
license?: string; // 许可证
|
||||
keywords?: string[]; // 关键词
|
||||
ccrVersion?: string; // 兼容的 CCR 版本
|
||||
source?: string; // 预设来源 URL
|
||||
name: string; // Preset name
|
||||
version: string; // Version number (semver)
|
||||
description?: string; // Description
|
||||
author?: string; // Author
|
||||
homepage?: string; // Homepage
|
||||
repository?: string; // Source repository
|
||||
license?: string; // License
|
||||
keywords?: string[]; // Keywords
|
||||
ccrVersion?: string; // Compatible CCR version
|
||||
source?: string; // Preset source URL
|
||||
sourceType?: 'local' | 'gist' | 'registry';
|
||||
checksum?: string; // 预设内容校验和
|
||||
checksum?: string; // Preset content checksum
|
||||
}
|
||||
|
||||
// 预设配置部分
|
||||
// Preset configuration section
|
||||
export interface PresetConfigSection {
|
||||
Providers?: ProviderConfig[];
|
||||
Router?: RouterConfig;
|
||||
@@ -145,103 +150,108 @@ export interface PresetConfigSection {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 模板配置(用于根据用户输入动态生成配置)
|
||||
// Template configuration (for dynamically generating configuration based on user input)
|
||||
export interface TemplateConfig {
|
||||
// 使用 {{variable}} 语法的模板配置
|
||||
// 例如:{ "Providers": [{ "name": "{{providerName}}", "api_key": "{{apiKey}}" }] }
|
||||
// Template configuration using {{variable}} syntax
|
||||
// Example: { "Providers": [{ "name": "{{providerName}}", "api_key": "{{apiKey}}" }] }
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 配置映射(将用户输入的值映射到配置的具体位置)
|
||||
// Configuration mapping (maps user input values to specific configuration locations)
|
||||
export interface ConfigMapping {
|
||||
// 字段路径(支持数组语法,如 "Providers[0].api_key")
|
||||
// Field path (supports array syntax, e.g. "Providers[0].api_key")
|
||||
target: string;
|
||||
|
||||
// 值来源(引用用户输入的 id,或使用固定值)
|
||||
value: string | any; // 如果是 string 且以 {{ 开头,则作为变量引用
|
||||
// Value source (references user input id, or uses fixed value)
|
||||
value: string | any; // If string and starts with {{, treated as variable reference
|
||||
|
||||
// 条件(可选,满足条件时才应用此映射)
|
||||
// Condition (optional, apply this mapping only when condition is met)
|
||||
when?: Condition | Condition[];
|
||||
}
|
||||
|
||||
// 完整的预设文件格式
|
||||
// Complete preset file format
|
||||
export interface PresetFile {
|
||||
metadata?: PresetMetadata;
|
||||
config: PresetConfigSection;
|
||||
secrets?: {
|
||||
// 敏感信息存储,格式:字段路径 -> 值
|
||||
// 例如:{ "Providers[0].api_key": "sk-xxx", "APIKEY": "my-secret" }
|
||||
// Sensitive information storage, format: field path -> value
|
||||
// Example: { "Providers[0].api_key": "sk-xxx", "APIKEY": "my-secret" }
|
||||
[fieldPath: string]: string;
|
||||
};
|
||||
|
||||
// === 动态配置系统 ===
|
||||
// 配置输入schema
|
||||
// === Dynamic configuration system ===
|
||||
// Configuration input schema
|
||||
schema?: RequiredInput[];
|
||||
|
||||
// 配置模板(使用变量替换)
|
||||
// Configuration template (uses variable replacement)
|
||||
template?: TemplateConfig;
|
||||
|
||||
// 配置映射(将用户输入映射到配置)
|
||||
// Configuration mappings (maps user input to configuration)
|
||||
configMappings?: ConfigMapping[];
|
||||
}
|
||||
|
||||
// manifest.json 格式(压缩包内的文件)
|
||||
// manifest.json format (file inside ZIP archive)
|
||||
export interface ManifestFile extends PresetMetadata, PresetConfigSection {
|
||||
// === 动态配置系统 ===
|
||||
// === Dynamic configuration system ===
|
||||
schema?: RequiredInput[];
|
||||
template?: TemplateConfig;
|
||||
configMappings?: ConfigMapping[];
|
||||
|
||||
// === User configuration value storage ===
|
||||
// User-filled configuration values are stored separately from original configuration
|
||||
// Values collected during installation are stored here, applied at runtime
|
||||
userValues?: UserInputValues;
|
||||
}
|
||||
|
||||
// 在线预设索引条目
|
||||
// Online preset index entry
|
||||
export interface PresetIndexEntry {
|
||||
id: string; // 唯一标识
|
||||
name: string; // 显示名称
|
||||
description?: string; // 简短描述
|
||||
version: string; // 最新版本
|
||||
author?: string; // 作者
|
||||
downloads?: number; // 下载次数
|
||||
stars?: number; // 点赞数
|
||||
tags?: string[]; // 标签
|
||||
url: string; // 下载地址
|
||||
checksum?: string; // SHA256 校验和
|
||||
ccrVersion?: string; // 兼容版本
|
||||
id: string; // Unique identifier
|
||||
name: string; // Display name
|
||||
description?: string; // Short description
|
||||
version: string; // Latest version
|
||||
author?: string; // Author
|
||||
downloads?: number; // Download count
|
||||
stars?: number; // Star count
|
||||
tags?: string[]; // Tags
|
||||
url: string; // Download address
|
||||
checksum?: string; // SHA256 checksum
|
||||
ccrVersion?: string; // Compatible version
|
||||
}
|
||||
|
||||
// 在线预设仓库索引
|
||||
// Online preset repository index
|
||||
export interface PresetRegistry {
|
||||
version: string; // 索引格式版本
|
||||
lastUpdated: string; // 最后更新时间
|
||||
version: string; // Index format version
|
||||
lastUpdated: string; // Last update time
|
||||
presets: PresetIndexEntry[];
|
||||
}
|
||||
|
||||
// 配置验证结果
|
||||
// Configuration validation result
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// 合并策略枚举
|
||||
// Merge strategy enumeration
|
||||
export enum MergeStrategy {
|
||||
ASK = 'ask', // 交互式询问
|
||||
OVERWRITE = 'overwrite', // 覆盖现有
|
||||
MERGE = 'merge', // 智能合并
|
||||
SKIP = 'skip', // 跳过冲突项
|
||||
ASK = 'ask', // Interactive prompt
|
||||
OVERWRITE = 'overwrite', // Overwrite existing
|
||||
MERGE = 'merge', // Intelligent merge
|
||||
SKIP = 'skip', // Skip conflicting items
|
||||
}
|
||||
|
||||
// 脱敏结果
|
||||
// Sanitization result
|
||||
export interface SanitizeResult {
|
||||
sanitizedConfig: any;
|
||||
requiredInputs: RequiredInput[];
|
||||
sanitizedCount: number;
|
||||
}
|
||||
|
||||
// Preset 信息(用于列表展示)
|
||||
// Preset information (for list display)
|
||||
export interface PresetInfo {
|
||||
name: string; // 预设名称
|
||||
version?: string; // 版本号
|
||||
description?: string; // 描述
|
||||
author?: string; // 作者
|
||||
name: string; // Preset name
|
||||
version?: string; // Version number
|
||||
description?: string; // Description
|
||||
author?: string; // Author
|
||||
config: PresetConfigSection;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Upload, Link, Trash2, Info, Download, CheckCircle2, AlertCircle, Loader
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
import { DynamicConfigForm } from "./preset/DynamicConfigForm";
|
||||
|
||||
// Schema 类型
|
||||
// Schema types
|
||||
interface InputOption {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
@@ -87,6 +87,7 @@ interface PresetDetail extends PresetMetadata {
|
||||
schema?: RequiredInput[];
|
||||
template?: any;
|
||||
configMappings?: any[];
|
||||
userValues?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface MarketPreset {
|
||||
@@ -126,7 +127,7 @@ export function Presets() {
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
// 加载市场预设
|
||||
// Load market presets
|
||||
const loadMarketPresets = async () => {
|
||||
setMarketLoading(true);
|
||||
try {
|
||||
@@ -140,44 +141,51 @@ export function Presets() {
|
||||
}
|
||||
};
|
||||
|
||||
// 从市场安装预设
|
||||
// Install preset from market
|
||||
const handleInstallFromMarket = async (preset: MarketPreset) => {
|
||||
try {
|
||||
setInstallingFromMarket(preset.id);
|
||||
|
||||
// 第一步:安装预设(解压到目录)
|
||||
await api.installPresetFromGitHub(preset.repo, preset.name);
|
||||
// Step 1: Install preset (extract to directory)
|
||||
const installResult = await api.installPresetFromGitHub(preset.repo);
|
||||
|
||||
// 第二步:获取预设详情(检查是否需要配置)
|
||||
// Step 2: Get preset details (check if configuration is required)
|
||||
try {
|
||||
const detail = await api.getPreset(preset.name);
|
||||
const presetDetail: PresetDetail = { ...preset, ...detail };
|
||||
const installedPresetName = installResult.presetName || preset.name;
|
||||
const detail = await api.getPreset(installedPresetName);
|
||||
const presetDetail: PresetDetail = { ...preset, ...detail, id: installedPresetName };
|
||||
|
||||
// 检查是否需要配置
|
||||
// Check if configuration is required
|
||||
if (detail.schema && detail.schema.length > 0) {
|
||||
// 需要配置,打开配置对话框
|
||||
// Configuration required, open configuration dialog
|
||||
setSelectedPreset(presetDetail);
|
||||
|
||||
// 初始化默认值
|
||||
// Initialize form values: prefer saved userValues, otherwise use defaultValue
|
||||
const initialValues: Record<string, any> = {};
|
||||
for (const input of detail.schema) {
|
||||
initialValues[input.id] = input.defaultValue ?? '';
|
||||
// Prefer saved values
|
||||
if (detail.userValues && detail.userValues[input.id] !== undefined) {
|
||||
initialValues[input.id] = detail.userValues[input.id];
|
||||
} else {
|
||||
// Otherwise use default value
|
||||
initialValues[input.id] = input.defaultValue ?? '';
|
||||
}
|
||||
}
|
||||
setSecrets(initialValues);
|
||||
|
||||
// 关闭市场对话框,打开详情对话框
|
||||
// Close market dialog, open details dialog
|
||||
setMarketDialogOpen(false);
|
||||
setDetailDialogOpen(true);
|
||||
|
||||
setToast({ message: t('presets.preset_installed_config_required'), type: 'warning' });
|
||||
} else {
|
||||
// 不需要配置,直接完成
|
||||
// No configuration required, complete directly
|
||||
setToast({ message: t('presets.preset_installed'), type: 'success' });
|
||||
setMarketDialogOpen(false);
|
||||
await loadPresets();
|
||||
}
|
||||
} catch (error) {
|
||||
// 获取详情失败,但安装成功了,刷新列表
|
||||
// Failed to get details, but installation succeeded, refresh list
|
||||
console.error('Failed to get preset details after installation:', error);
|
||||
setToast({ message: t('presets.preset_installed'), type: 'success' });
|
||||
setMarketDialogOpen(false);
|
||||
@@ -191,21 +199,21 @@ export function Presets() {
|
||||
}
|
||||
};
|
||||
|
||||
// 打开市场对话框时加载预设
|
||||
// Load presets when opening market dialog
|
||||
useEffect(() => {
|
||||
if (marketDialogOpen && marketPresets.length === 0) {
|
||||
loadMarketPresets();
|
||||
}
|
||||
}, [marketDialogOpen]);
|
||||
|
||||
// 过滤市场预设
|
||||
// Filter market presets
|
||||
const filteredMarketPresets = marketPresets.filter(preset =>
|
||||
preset.name.toLowerCase().includes(marketSearch.toLowerCase()) ||
|
||||
preset.description?.toLowerCase().includes(marketSearch.toLowerCase()) ||
|
||||
preset.author?.toLowerCase().includes(marketSearch.toLowerCase())
|
||||
);
|
||||
|
||||
// 加载预设列表
|
||||
// Load presets list
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -223,18 +231,24 @@ export function Presets() {
|
||||
loadPresets();
|
||||
}, []);
|
||||
|
||||
// 查看预设详情
|
||||
// View preset details
|
||||
const handleViewDetail = async (preset: PresetMetadata) => {
|
||||
try {
|
||||
const detail = await api.getPreset(preset.id);
|
||||
setSelectedPreset({ ...preset, ...detail });
|
||||
setDetailDialogOpen(true);
|
||||
|
||||
// 初始化默认值
|
||||
// 初始化表单值:优先使用已保存的 userValues,否则使用 defaultValue
|
||||
if (detail.schema && detail.schema.length > 0) {
|
||||
const initialValues: Record<string, any> = {};
|
||||
for (const input of detail.schema) {
|
||||
initialValues[input.id] = input.defaultValue ?? '';
|
||||
// 优先使用已保存的值
|
||||
if (detail.userValues && detail.userValues[input.id] !== undefined) {
|
||||
initialValues[input.id] = detail.userValues[input.id];
|
||||
} else {
|
||||
// Otherwise use default value
|
||||
initialValues[input.id] = input.defaultValue ?? '';
|
||||
}
|
||||
}
|
||||
setSecrets(initialValues);
|
||||
}
|
||||
@@ -266,38 +280,47 @@ export function Presets() {
|
||||
: installUrl!.split('/').pop()!.replace('.ccrsets', '')
|
||||
);
|
||||
|
||||
// 第一步:安装预设(解压到目录)
|
||||
// Step 1: Install preset (extract to directory)
|
||||
let installResult;
|
||||
if (installMethod === 'url' && installUrl) {
|
||||
await api.installPresetFromUrl(installUrl, presetName);
|
||||
installResult = await api.installPresetFromUrl(installUrl, presetName);
|
||||
} else if (installMethod === 'file' && installFile) {
|
||||
await api.uploadPresetFile(installFile, presetName);
|
||||
installResult = await api.uploadPresetFile(installFile, presetName);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// 第二步:获取预设详情(检查是否需要配置)
|
||||
// Step 2: Get preset details (check if configuration is required)
|
||||
try {
|
||||
const detail = await api.getPreset(presetName);
|
||||
// 使用服务器返回的实际预设名称
|
||||
const actualPresetName = installResult?.presetName || presetName;
|
||||
const detail = await api.getPreset(actualPresetName);
|
||||
|
||||
// 检查是否需要配置
|
||||
// Check if configuration is required
|
||||
if (detail.schema && detail.schema.length > 0) {
|
||||
// 需要配置,打开配置对话框
|
||||
// Configuration required, open configuration dialog
|
||||
setSelectedPreset({
|
||||
id: presetName,
|
||||
name: presetName,
|
||||
id: actualPresetName,
|
||||
name: detail.name || actualPresetName,
|
||||
version: detail.version || '1.0.0',
|
||||
installed: true,
|
||||
...detail
|
||||
});
|
||||
|
||||
// 初始化默认值
|
||||
// Initialize form values: prefer saved userValues, otherwise use defaultValue
|
||||
const initialValues: Record<string, any> = {};
|
||||
for (const input of detail.schema) {
|
||||
initialValues[input.id] = input.defaultValue ?? '';
|
||||
// Prefer saved values
|
||||
if (detail.userValues && detail.userValues[input.id] !== undefined) {
|
||||
initialValues[input.id] = detail.userValues[input.id];
|
||||
} else {
|
||||
// Otherwise use default value
|
||||
initialValues[input.id] = input.defaultValue ?? '';
|
||||
}
|
||||
}
|
||||
setSecrets(initialValues);
|
||||
|
||||
// 关闭安装对话框,打开详情对话框
|
||||
// Close installation dialog, open details dialog
|
||||
setInstallDialogOpen(false);
|
||||
setInstallUrl('');
|
||||
setInstallFile(null);
|
||||
@@ -306,7 +329,7 @@ export function Presets() {
|
||||
|
||||
setToast({ message: t('presets.preset_installed_config_required'), type: 'warning' });
|
||||
} else {
|
||||
// 不需要配置,直接完成
|
||||
// No configuration required, complete directly
|
||||
setToast({ message: t('presets.preset_installed'), type: 'success' });
|
||||
setInstallDialogOpen(false);
|
||||
setInstallUrl('');
|
||||
@@ -315,7 +338,7 @@ export function Presets() {
|
||||
await loadPresets();
|
||||
}
|
||||
} catch (error) {
|
||||
// 获取详情失败,但安装成功了,刷新列表
|
||||
// Failed to get details, but installation succeeded, refresh list
|
||||
console.error('Failed to get preset details after installation:', error);
|
||||
setToast({ message: t('presets.preset_installed'), type: 'success' });
|
||||
setInstallDialogOpen(false);
|
||||
@@ -332,20 +355,24 @@ export function Presets() {
|
||||
}
|
||||
};
|
||||
|
||||
// 应用预设(配置敏感信息)
|
||||
// Apply preset (configure sensitive information)
|
||||
const handleApplyPreset = async (values?: Record<string, any>) => {
|
||||
try {
|
||||
setIsApplying(true);
|
||||
|
||||
// 使用传入的values或现有的secrets
|
||||
// Use passed values or existing secrets
|
||||
const inputValues = values || secrets;
|
||||
|
||||
// 验证所有必填项都已填写
|
||||
// Verify all required fields are filled
|
||||
if (selectedPreset?.schema && selectedPreset.schema.length > 0) {
|
||||
// 验证在 DynamicConfigForm 中已完成
|
||||
// 这里只做简单检查
|
||||
// Validation completed in DynamicConfigForm
|
||||
// 这里只做简单检查(对于 confirm 类型,false 是有效值)
|
||||
for (const input of selectedPreset.schema) {
|
||||
if (input.required !== false && !inputValues[input.id]) {
|
||||
const value = inputValues[input.id];
|
||||
const isEmpty = value === undefined || value === null || value === '' ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (input.required !== false && isEmpty) {
|
||||
setToast({ message: t('presets.please_fill_field', { field: input.label || input.id }), type: 'warning' });
|
||||
setIsApplying(false);
|
||||
return;
|
||||
@@ -353,11 +380,11 @@ export function Presets() {
|
||||
}
|
||||
}
|
||||
|
||||
await api.applyPreset(selectedPreset!.name, inputValues);
|
||||
await api.applyPreset(selectedPreset!.id, inputValues);
|
||||
setToast({ message: t('presets.preset_applied'), type: 'success' });
|
||||
setDetailDialogOpen(false);
|
||||
setSecrets({});
|
||||
// 刷新预设列表
|
||||
// Refresh presets list
|
||||
await loadPresets();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to apply preset:', error);
|
||||
@@ -367,7 +394,7 @@ export function Presets() {
|
||||
}
|
||||
};
|
||||
|
||||
// 删除预设
|
||||
// Delete preset
|
||||
const handleDelete = async () => {
|
||||
if (!presetToDelete) return;
|
||||
|
||||
@@ -576,7 +603,7 @@ export function Presets() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置表单 */}
|
||||
{/* Configuration form */}
|
||||
{selectedPreset?.schema && selectedPreset.schema.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="font-medium text-sm mb-4">{t('presets.required_information')}</h4>
|
||||
@@ -591,11 +618,6 @@ export function Presets() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDetailDialogOpen(false)}>
|
||||
{t('presets.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -11,9 +12,9 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
// 类型定义
|
||||
// Type definitions
|
||||
interface InputOption {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
@@ -77,11 +78,12 @@ export function DynamicConfigForm({
|
||||
isSubmitting = false,
|
||||
initialValues = {},
|
||||
}: DynamicConfigFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<Record<string, any>>(initialValues);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [visibleFields, setVisibleFields] = useState<Set<string>>(new Set());
|
||||
|
||||
// 计算可见字段
|
||||
// Calculate visible fields
|
||||
useEffect(() => {
|
||||
const updateVisibility = () => {
|
||||
const visible = new Set<string>();
|
||||
@@ -98,7 +100,7 @@ export function DynamicConfigForm({
|
||||
updateVisibility();
|
||||
}, [values, schema]);
|
||||
|
||||
// 评估条件
|
||||
// Evaluate condition
|
||||
const evaluateCondition = (condition: Condition): boolean => {
|
||||
const actualValue = values[condition.field];
|
||||
|
||||
@@ -132,7 +134,7 @@ export function DynamicConfigForm({
|
||||
}
|
||||
};
|
||||
|
||||
// 判断字段是否应该显示
|
||||
// Determine if field should be displayed
|
||||
const shouldShowField = (field: RequiredInput): boolean => {
|
||||
if (!field.when) {
|
||||
return true;
|
||||
@@ -142,7 +144,7 @@ export function DynamicConfigForm({
|
||||
return conditions.every(condition => evaluateCondition(condition));
|
||||
};
|
||||
|
||||
// 获取选项列表
|
||||
// Get options list
|
||||
const getOptions = (field: RequiredInput): InputOption[] => {
|
||||
if (!field.options) {
|
||||
return [];
|
||||
@@ -197,13 +199,13 @@ export function DynamicConfigForm({
|
||||
return [];
|
||||
};
|
||||
|
||||
// 更新字段值
|
||||
// Update field value
|
||||
const updateValue = (fieldId: string, value: any) => {
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}));
|
||||
// 清除该字段的错误
|
||||
// Clear errors for this field
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldId];
|
||||
@@ -211,44 +213,44 @@ export function DynamicConfigForm({
|
||||
});
|
||||
};
|
||||
|
||||
// 验证单个字段
|
||||
// Validate single field
|
||||
const validateField = (field: RequiredInput): string | null => {
|
||||
const value = values[field.id];
|
||||
const fieldName = field.label || field.id;
|
||||
|
||||
// 检查必填
|
||||
if (field.required !== false && (value === undefined || value === null || value === '')) {
|
||||
return `${field.label || field.id} is required`;
|
||||
// Check required (for confirm type, false is a valid value)
|
||||
const isEmpty = value === undefined || value === null || value === '' ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (field.required !== false && isEmpty) {
|
||||
return t('presets.form.field_required', { field: fieldName });
|
||||
}
|
||||
|
||||
if (!value && field.required === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 类型检查
|
||||
if (field.type === 'number' && isNaN(Number(value))) {
|
||||
return `${field.label || field.id} must be a number`;
|
||||
// Type check
|
||||
if (field.type === 'number' && value !== '' && isNaN(Number(value))) {
|
||||
return t('presets.form.must_be_number', { field: fieldName });
|
||||
}
|
||||
|
||||
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}`;
|
||||
return t('presets.form.must_be_at_least', { field: fieldName, min: field.min });
|
||||
}
|
||||
if (field.max !== undefined && numValue > field.max) {
|
||||
return `${field.label || field.id} must be at most ${field.max}`;
|
||||
return t('presets.form.must_be_at_most', { field: fieldName, max: field.max });
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义验证器
|
||||
if (field.validator) {
|
||||
// Custom validator
|
||||
if (field.validator && value !== '') {
|
||||
if (field.validator instanceof RegExp) {
|
||||
if (!field.validator.test(String(value))) {
|
||||
return `${field.label || field.id} format is invalid`;
|
||||
return t('presets.form.format_invalid', { field: fieldName });
|
||||
}
|
||||
} 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 t('presets.form.format_invalid', { field: fieldName });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,11 +258,11 @@ export function DynamicConfigForm({
|
||||
return null;
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
// Submit form
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 验证所有可见字段
|
||||
// Validate all visible fields
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
for (const field of schema) {
|
||||
@@ -338,7 +340,7 @@ export function DynamicConfigForm({
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id={`field-${field.id}`}>
|
||||
<SelectValue placeholder={field.placeholder || `Select ${label}`} />
|
||||
<SelectValue placeholder={field.placeholder || t('presets.form.select', { label })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getOptions(field).map((option) => (
|
||||
@@ -432,19 +434,16 @@ export function DynamicConfigForm({
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('app.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Applying...
|
||||
{t('presets.form.saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Apply
|
||||
</>
|
||||
t('app.save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -43,12 +43,12 @@ export function Toast({ message, type, onClose }: ToastProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`fixed top-4 right-4 z-50 flex items-center justify-between p-4 rounded-lg border shadow-lg ${getBackgroundColor()} transition-all duration-300 ease-in-out`}>
|
||||
<div className={`fixed top-4 right-4 z-[100] flex items-center justify-between p-4 rounded-lg border shadow-lg ${getBackgroundColor()} transition-all duration-300 ease-in-out`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getIcon()}
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
>
|
||||
|
||||
@@ -285,6 +285,17 @@
|
||||
"load_market_failed": "Failed to load market presets",
|
||||
"preset_installed_config_required": "Preset installed, please complete configuration",
|
||||
"please_provide_file": "Please provide a preset file",
|
||||
"please_provide_url": "Please provide a preset URL"
|
||||
"please_provide_url": "Please provide a preset URL",
|
||||
"form": {
|
||||
"field_required": "{{field}} is required",
|
||||
"must_be_number": "{{field}} must be a number",
|
||||
"must_be_at_least": "{{field}} must be at least {{min}}",
|
||||
"must_be_at_most": "{{field}} must be at most {{max}}",
|
||||
"format_invalid": "{{field}} format is invalid",
|
||||
"select": "Select {{label}}",
|
||||
"applying": "Applying...",
|
||||
"apply": "Apply",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,17 @@
|
||||
"load_market_failed": "加载市场预设失败",
|
||||
"preset_installed_config_required": "预设已安装,请完成配置",
|
||||
"please_provide_file": "请提供预设文件",
|
||||
"please_provide_url": "请提供预设 URL"
|
||||
"please_provide_url": "请提供预设 URL",
|
||||
"form": {
|
||||
"field_required": "{{field}} 为必填项",
|
||||
"must_be_number": "{{field}} 必须是数字",
|
||||
"must_be_at_least": "{{field}} 至少为 {{min}}",
|
||||
"must_be_at_most": "{{field}} 最多为 {{max}}",
|
||||
"format_invalid": "{{field}} 格式无效",
|
||||
"select": "选择 {{label}}",
|
||||
"applying": "应用中...",
|
||||
"apply": "应用",
|
||||
"cancel": "取消"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user