diff --git a/packages/cli/src/utils/preset/commands.ts b/packages/cli/src/utils/preset/commands.ts index a4be066..b312b2b 100644 --- a/packages/cli/src/utils/preset/commands.ts +++ b/packages/cli/src/utils/preset/commands.ts @@ -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 { const presetsDir = path.join(HOME_DIR, 'presets'); @@ -51,7 +50,7 @@ async function listPresets(): Promise { 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 { 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 { } /** - * 删除预设 + * Delete preset */ async function deletePreset(name: string): Promise { 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 { } /** - * 显示预设信息 + * Show preset information */ async function showPresetInfo(name: string): Promise { 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 { 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 { } /** - * 处理预设命令 + * Handle preset commands */ export async function handlePresetCommand(args: string[]): Promise { const subCommand = args[0]; @@ -172,7 +179,7 @@ export async function handlePresetCommand(args: string[]): Promise { 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 { 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': diff --git a/packages/cli/src/utils/preset/install.ts b/packages/cli/src/utils/preset/install.ts index cae167f..822e22f 100644 --- a/packages/cli/src/utils/preset/install.ts +++ b/packages/cli/src/utils/preset/install.ts @@ -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 { - 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); } diff --git a/packages/core/src/api/routes.ts b/packages/core/src/api/routes.ts index 5896a57..5ef76a9 100644 --- a/packages/core/src/api/routes.ts +++ b/packages/core/src/api/routes.ts @@ -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 = { 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 ); diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 796e9dc..660db69 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -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 { @@ -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), diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index ba580a7..76fc5cc 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -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 => { 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 => { } } - // 按修改时间倒序排列 + // 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 => { } }); - // 获取日志内容端点 + // 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 => { } }); - // 清除日志内容端点 + // 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 => { } }); - // 获取预设列表 + // 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 => { 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 => { } }); - // 获取预设详情 + // 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 => { } 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 => { }; } - // 如果没有 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 => { } }); - // 上传预设文件(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 => { 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 => { } }); - // 应用预设(配置敏感信息) + // 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 => { 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 => { } }); - // 删除预设 + // 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 => { 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 => { } }); - // 获取预设市场列表 + // 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 => { } }); - // 从 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 => { 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 => { 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 => { } }); - // 辅助函数:从 ZIP 加载预设 + // Helper function: Load preset from ZIP async function loadPresetFromZip(zipFile: string): Promise { 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; } diff --git a/packages/shared/src/preset/install.ts b/packages/shared/src/preset/install.ts index aeb8364..ae72a3e 100644 --- a/packages/shared/src/preset/install.ts +++ b/packages/shared/src/preset/install.ts @@ -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 { - // 检查目标目录是否已存在 + // 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(); 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 { const manifestPath = path.join(presetDir, 'manifest.json'); @@ -113,21 +162,68 @@ export async function readManifestFromDir(presetDir: string): Promise 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 { const response = await fetch(url); @@ -136,7 +232,7 @@ export async function downloadPresetToTemp(url: string): Promise { } 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 { } /** - * 从本地ZIP文件加载预设 - * @param zipFile ZIP文件路径 + * Load preset from local ZIP file + * @param zipFile ZIP file path * @returns PresetFile */ export async function loadPresetFromZip(zipFile: string): Promise { @@ -162,33 +258,33 @@ export async function loadPresetFromZip(zipFile: string): Promise { } /** - * 加载预设文件 - * @param source 预设来源(文件路径、URL 或预设名称) + * Load preset file + * @param source Preset source (file path, URL, or preset name) */ export async function loadPreset(source: string): Promise { - // 判断是否是 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 { 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 { - // 当前目录文件 + // 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 { } /** - * 检查预设是否已安装 - * @param presetName 预设名称 + * Check if preset is already installed + * @param presetName Preset name */ export async function isPresetInstalled(presetName: string): Promise { const presetDir = getPresetDir(presetName); @@ -290,8 +412,8 @@ export async function isPresetInstalled(presetName: string): Promise { } /** - * 列出所有已安装的预设 - * @returns PresetInfo 数组 + * List all installed presets + * @returns Array of PresetInfo */ export async function listPresets(): Promise { const presetsDir = PRESETS_DIR; @@ -303,7 +425,7 @@ export async function listPresets(): Promise { 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 { 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 { 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; } } diff --git a/packages/shared/src/preset/readPreset.ts b/packages/shared/src/preset/readPreset.ts index 95c8e51..b112928 100644 --- a/packages/shared/src/preset/readPreset.ts +++ b/packages/shared/src/preset/readPreset.ts @@ -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 { 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') { diff --git a/packages/shared/src/preset/schema.ts b/packages/shared/src/preset/schema.ts index 7d32d01..d80d416 100644 --- a/packages/shared/src/preset/schema.ts +++ b/packages/shared/src/preset/schema.ts @@ -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 { + 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(); - // 从 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(); 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; +} diff --git a/packages/shared/src/preset/types.ts b/packages/shared/src/preset/types.ts index 9d0fc55..6ea4877 100644 --- a/packages/shared/src/preset/types.ts +++ b/packages/shared/src/preset/types.ts @@ -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; @@ -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; } diff --git a/packages/ui/src/components/Presets.tsx b/packages/ui/src/components/Presets.tsx index f61c3a1..618098e 100644 --- a/packages/ui/src/components/Presets.tsx +++ b/packages/ui/src/components/Presets.tsx @@ -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; } 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 = {}; 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 = {}; 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 = {}; 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) => { 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() { )} - {/* 配置表单 */} + {/* Configuration form */} {selectedPreset?.schema && selectedPreset.schema.length > 0 && (

{t('presets.required_information')}

@@ -591,11 +618,6 @@ export function Presets() {
)} - - - diff --git a/packages/ui/src/components/preset/DynamicConfigForm.tsx b/packages/ui/src/components/preset/DynamicConfigForm.tsx index 242f18e..7948763 100644 --- a/packages/ui/src/components/preset/DynamicConfigForm.tsx +++ b/packages/ui/src/components/preset/DynamicConfigForm.tsx @@ -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>(initialValues); const [errors, setErrors] = useState>({}); const [visibleFields, setVisibleFields] = useState>(new Set()); - // 计算可见字段 + // Calculate visible fields useEffect(() => { const updateVisibility = () => { const visible = new Set(); @@ -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 = {}; for (const field of schema) { @@ -338,7 +340,7 @@ export function DynamicConfigForm({ disabled={isSubmitting} > - + {getOptions(field).map((option) => ( @@ -432,19 +434,16 @@ export function DynamicConfigForm({ onClick={onCancel} disabled={isSubmitting} > - Cancel + {t('app.cancel')} diff --git a/packages/ui/src/components/ui/toast.tsx b/packages/ui/src/components/ui/toast.tsx index c82d248..3ac4574 100644 --- a/packages/ui/src/components/ui/toast.tsx +++ b/packages/ui/src/components/ui/toast.tsx @@ -43,12 +43,12 @@ export function Toast({ message, type, onClose }: ToastProps) { }; return ( -
+
{getIcon()} {message}
-