finished presets

This commit is contained in:
musistudio
2025-12-30 16:49:16 +08:00
parent 06a18c0734
commit 559f5024c4
14 changed files with 869 additions and 588 deletions

View File

@@ -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':

View File

@@ -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);
}

View File

@@ -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);
}
/**
* 处理请求转换器链
* 依次执行transformRequestOutprovider transformersmodel-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;
}
// 执行transformertransformRequestOut方法
// 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 transformersmodel-specific transformerstransformertransformResponseIn
* 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
);
}
}
// 执行transformertransformResponseIn方法
// 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
);

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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') {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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) {
// 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,19 +231,25 @@ 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) {
// 优先使用已保存的值
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);
}
} catch (error) {
@@ -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) {
// 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>

View File

@@ -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>

View File

@@ -43,7 +43,7 @@ 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>

View File

@@ -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"
}
}
}

View File

@@ -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": "取消"
}
}
}