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