add presets

This commit is contained in:
musistudio
2025-12-27 21:51:32 +08:00
parent 837ff8e9e4
commit a0ec618f4d
28 changed files with 3423 additions and 103 deletions

View File

@@ -18,14 +18,17 @@
"author": "musistudio",
"license": "MIT",
"dependencies": {
"@CCR/server": "workspace:*",
"@CCR/shared": "workspace:*",
"@inquirer/prompts": "^5.0.0",
"@CCR/server": "workspace:*",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"find-process": "^2.0.0",
"minimist": "^1.2.8",
"openurl": "^1.1.1"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/node": "^24.0.15",
"esbuild": "^0.25.1",
"ts-node": "^10.9.2",

View File

@@ -28,6 +28,7 @@ const KNOWN_COMMANDS = [
"statusline",
"code",
"model",
"preset",
"activate",
"env",
"ui",
@@ -48,6 +49,7 @@ Commands:
statusline Integrated statusline
code Execute claude command
model Interactive model selection and configuration
preset Manage presets (export, install, list, delete)
activate Output environment variables for shell integration
ui Open the web UI in browser
-v, version Show version information
@@ -61,6 +63,9 @@ Examples:
ccr code "Write a Hello World"
ccr my-preset "Write a Hello World" # Use preset configuration
ccr model
ccr preset export my-config # Export current config as preset
ccr preset install my-config.ccrsets # Install a preset
ccr preset list # List all presets
eval "$(ccr activate)" # Set environment variables globally
ccr ui
`;
@@ -91,52 +96,62 @@ async function main() {
// 如果命令不是已知命令,检查是否是 preset
if (command && !KNOWN_COMMANDS.includes(command)) {
const { readPresetFile } = await import("./utils");
const presetConfig: PresetConfig | null = await readPresetFile(command);
const presetData: any = await readPresetFile(command);
if (presetConfig) {
if (presetData) {
// 这是一个 preset执行 code 命令
const codeArgs = process.argv.slice(3); // 获取剩余参数
// 检查 noServer 配置
const shouldStartServer = presetConfig.noServer !== true;
const shouldStartServer = presetData.noServer !== true;
// 处理 provider 配置,构建环境变量覆盖
// 构建环境变量覆盖
let envOverrides: Record<string, string> | undefined;
if (presetConfig.provider) {
// 处理 provider 配置(支持新旧两种格式)
let provider: any = null;
// 旧格式presetData.provider 是 provider 名称
if (presetData.provider && typeof presetData.provider === 'string') {
const config = await readConfigFile();
const providerName = presetConfig.provider;
const provider = config.Providers?.find((p: any) => p.name === providerName);
provider = config.Providers?.find((p: any) => p.name === presetData.provider);
}
// 新格式presetData.Providers 是 provider 数组
else if (presetData.Providers && presetData.Providers.length > 0) {
provider = presetData.Providers[0];
}
if (provider) {
// 处理 api_base_url去掉 /v1/messages 后缀
if (provider.api_base_url) {
let baseUrl = provider.api_base_url;
if (baseUrl.endsWith('/v1/messages')) {
baseUrl = baseUrl.slice(0, -'/v1/messages'.length);
} else if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
envOverrides = {
...envOverrides,
ANTHROPIC_BASE_URL: baseUrl,
};
if (provider) {
// 处理 api_base_url去掉 /v1/messages 后缀
if (provider.api_base_url) {
let baseUrl = provider.api_base_url;
if (baseUrl.endsWith('/v1/messages')) {
baseUrl = baseUrl.slice(0, -'/v1/messages'.length);
} else if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
envOverrides = {
...envOverrides,
ANTHROPIC_BASE_URL: baseUrl,
};
}
// 处理 api_key
if (provider.api_key) {
envOverrides = {
...envOverrides,
ANTHROPIC_AUTH_TOKEN: provider.api_key,
};
}
} else {
console.error(`Provider "${providerName}" not found in config`);
process.exit(1);
// 处理 api_key
if (provider.api_key) {
envOverrides = {
...envOverrides,
ANTHROPIC_AUTH_TOKEN: provider.api_key,
};
}
}
// TODO: 处理 router 配置
// 如果 preset 中有 router 配置且需要启动 server可能需要临时修改配置文件
// 构建 PresetConfig
const presetConfig: PresetConfig = {
noServer: presetData.noServer,
claudeCodeSettings: presetData.claudeCodeSettings,
provider: presetData.provider,
router: presetData.router,
};
if (shouldStartServer && !isRunning) {
console.log("Service not running, starting service...");
@@ -232,6 +247,10 @@ async function main() {
case "model":
await runModelSelector();
break;
case "preset":
const { handlePresetCommand } = await import("./utils/preset");
await handlePresetCommand(process.argv.slice(3));
break;
case "activate":
case "env":
await activateCommand();

View File

@@ -9,6 +9,7 @@ import {
PLUGINS_DIR,
PRESETS_DIR,
REFERENCE_COUNT_FILE,
readPresetFile,
} from "@CCR/shared";
import { getServer } from "@CCR/server";
import { writeFileSync, existsSync, readFileSync } from "fs";
@@ -191,15 +192,15 @@ export const run = async (args: string[] = []) => {
// Save the PID of the background process
writeFileSync(PID_FILE, process.pid.toString());
server.app.post('/api/update/perform', async () => {
app.post('/api/update/perform', async () => {
return await performUpdate();
})
server.app.get('/api/update/check', async () => {
app.get('/api/update/check', async () => {
return await checkForUpdates(version);
})
server.app.post("/api/restart", async () => {
app.post("/api/restart", async () => {
setTimeout(async () => {
spawn("ccr", ["restart"], {
detached: true,
@@ -274,23 +275,3 @@ export const getSettingsPath = (content: string): string => {
return tempFilePath;
}
/**
* 读取 preset 配置文件
* @param name preset 名称
* @returns preset 配置对象,如果文件不存在则返回 null
*/
export const readPresetFile = async (name: string): Promise<any | null> => {
try {
const presetFile = path.join(PRESETS_DIR, `${name}.ccrsets`);
const content = await fs.readFile(presetFile, 'utf-8');
return JSON5.parse(content);
} catch (error: any) {
if (error.code === 'ENOENT') {
console.error(`Preset file not found: ${name}.ccrsets`);
} else {
console.error(`Failed to read preset file: ${error.message}`);
}
return null;
}
}

View File

@@ -0,0 +1,256 @@
/**
* 预设命令处理器 CLI 层
* 负责处理 CLI 交互,核心逻辑在 shared 包中
*/
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import JSON5 from 'json5';
import { exportPresetCli } from './export';
import { installPresetCli, loadPreset } from './install';
import { MergeStrategy, HOME_DIR } from '@CCR/shared';
// ANSI 颜色代码
const RESET = "\x1B[0m";
const GREEN = "\x1B[32m";
const YELLOW = "\x1B[33m";
const BOLDCYAN = "\x1B[1m\x1B[36m";
const BOLDYELLOW = "\x1B[1m\x1B[33m";
const DIM = "\x1B[2m";
/**
* 列出本地预设
*/
async function listPresets(): Promise<void> {
const presetsDir = path.join(HOME_DIR, 'presets');
try {
await fs.access(presetsDir);
} catch {
console.log('\nNo presets directory found.');
console.log(`\nCreate your first preset with: ${GREEN}ccr preset export <name>${RESET}\n`);
return;
}
const entries = await fs.readdir(presetsDir, { withFileTypes: true });
const presetDirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
if (presetDirs.length === 0) {
console.log('\nNo presets found.');
console.log(`\nInstall a preset with: ${GREEN}ccr preset install <file>${RESET}\n`);
return;
}
console.log(`\n${BOLDCYAN}Available presets:${RESET}\n`);
for (const dirName of presetDirs) {
const presetDir = path.join(presetsDir, dirName);
try {
const manifestPath = path.join(presetDir, 'manifest.json');
const content = await fs.readFile(manifestPath, 'utf-8');
const manifest = JSON5.parse(content);
// 从manifest中提取metadata字段
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 description = metadata.description || '';
const author = metadata.author || '';
const version = metadata.version;
// 显示预设名称
if (version) {
console.log(`${GREEN}${RESET} ${BOLDCYAN}${name}${RESET} (v${version})`);
} else {
console.log(`${GREEN}${RESET} ${BOLDCYAN}${name}${RESET}`);
}
// 显示描述
if (description) {
console.log(` ${description}`);
}
// 显示作者
if (author) {
console.log(` ${DIM}by ${author}${RESET}`);
}
console.log('');
} catch (error) {
console.log(`${YELLOW}${RESET} ${dirName}`);
console.log(` ${DIM}(Error reading preset)${RESET}\n`);
}
}
}
/**
* 删除预设
*/
async function deletePreset(name: string): Promise<void> {
const presetsDir = path.join(HOME_DIR, 'presets');
const presetDir = path.join(presetsDir, name);
try {
// 递归删除整个目录
await fs.rm(presetDir, { recursive: true, force: true });
console.log(`\n${GREEN}${RESET} Preset "${name}" deleted.\n`);
} catch (error: any) {
if (error.code === 'ENOENT') {
console.error(`\n${YELLOW}Error:${RESET} Preset "${name}" not found.\n`);
} else {
console.error(`\n${YELLOW}Error:${RESET} ${error.message}\n`);
}
process.exit(1);
}
}
/**
* 显示预设信息
*/
async function showPresetInfo(name: string): Promise<void> {
try {
const preset = await loadPreset(name);
const config = preset.config;
const metadata = preset.metadata || {};
console.log(`\n${BOLDCYAN}═══════════════════════════════════════════════${RESET}`);
if (metadata.name) {
console.log(`${BOLDCYAN}Preset: ${RESET}${metadata.name}`);
} else {
console.log(`${BOLDCYAN}Preset: ${RESET}${name}`);
}
console.log(`${BOLDCYAN}═══════════════════════════════════════════════${RESET}\n`);
if (metadata.version) console.log(`${BOLDCYAN}Version:${RESET} ${metadata.version}`);
if (metadata.description) console.log(`${BOLDCYAN}Description:${RESET} ${metadata.description}`);
if (metadata.author) console.log(`${BOLDCYAN}Author:${RESET} ${metadata.author}`);
const keywords = (metadata as any).keywords;
if (keywords && keywords.length > 0) {
console.log(`${BOLDCYAN}Keywords:${RESET} ${keywords.join(', ')}`);
}
console.log(`\n${BOLDCYAN}Configuration:${RESET}`);
if (config.Providers) {
console.log(` Providers: ${config.Providers.length}`);
}
if (config.Router) {
console.log(` Router rules: ${Object.keys(config.Router).length}`);
}
if (config.provider) {
console.log(` Provider: ${config.provider}`);
}
if (preset.requiredInputs && preset.requiredInputs.length > 0) {
console.log(`\n${BOLDYELLOW}Required inputs:${RESET}`);
for (const input of preset.requiredInputs) {
const envVar = input.placeholder || input.field;
console.log(` - ${input.field} ${DIM}(${envVar})${RESET}`);
}
}
console.log('');
} catch (error: any) {
console.error(`\n${YELLOW}Error:${RESET} ${error.message}\n`);
process.exit(1);
}
}
/**
* 处理预设命令
*/
export async function handlePresetCommand(args: string[]): Promise<void> {
const subCommand = args[0];
switch (subCommand) {
case 'export':
const presetName = args[1];
if (!presetName) {
console.error('\nError: Preset name is required\n');
console.error('Usage: ccr preset export <name> [--output <path>] [--description <text>] [--author <name>] [--tags <tags>]\n');
process.exit(1);
}
// 解析选项
const options: any = {};
for (let i = 2; i < args.length; i++) {
if (args[i] === '--output' && args[i + 1]) {
options.output = args[++i];
} else if (args[i] === '--description' && args[i + 1]) {
options.description = args[++i];
} else if (args[i] === '--author' && args[i + 1]) {
options.author = args[++i];
} else if (args[i] === '--tags' && args[i + 1]) {
options.tags = args[++i];
} else if (args[i] === '--include-sensitive') {
options.includeSensitive = true;
}
}
await exportPresetCli(presetName, options);
break;
case 'install':
const source = args[1];
if (!source) {
console.error('\nError: Preset source is required\n');
console.error('Usage: ccr preset install <file | url | name>\n');
process.exit(1);
}
// 解析选项
const installOptions: any = {};
for (let i = 2; i < args.length; i++) {
if (args[i] === '--strategy' && args[i + 1]) {
const strategy = args[++i];
if (['ask', 'overwrite', 'merge', 'skip'].includes(strategy)) {
installOptions.strategy = strategy as MergeStrategy;
} else {
console.error(`\nError: Invalid merge strategy "${strategy}"\n`);
console.error('Valid strategies: ask, overwrite, merge, skip\n');
process.exit(1);
}
}
}
await installPresetCli(source, installOptions);
break;
case 'list':
await listPresets();
break;
case 'delete':
case 'rm':
case 'remove':
const deleteName = args[1];
if (!deleteName) {
console.error('\nError: Preset name is required\n');
console.error('Usage: ccr preset delete <name>\n');
process.exit(1);
}
await deletePreset(deleteName);
break;
case 'info':
const infoName = args[1];
if (!infoName) {
console.error('\nError: Preset name is required\n');
console.error('Usage: ccr preset info <name>\n');
process.exit(1);
}
await showPresetInfo(infoName);
break;
default:
console.error(`\nError: Unknown preset command "${subCommand}"\n`);
console.error('Available commands:');
console.error(' ccr preset export <name> Export current configuration as a preset');
console.error(' ccr preset install <source> Install a preset from file, URL, or registry');
console.error(' ccr preset list List installed presets');
console.error(' ccr preset info <name> Show preset information');
console.error(' ccr preset delete <name> Delete a preset\n');
process.exit(1);
}
}

View File

@@ -0,0 +1,104 @@
/**
* 预设导出功能 CLI 层
* 负责处理 CLI 交互,核心逻辑在 shared 包中
*/
import { input } from '@inquirer/prompts';
import { readConfigFile } from '../index';
import { exportPreset as exportPresetCore, ExportOptions } from '@CCR/shared';
// ANSI 颜色代码
const RESET = "\x1B[0m";
const GREEN = "\x1B[32m";
const BOLDGREEN = "\x1B[1m\x1B[32m";
const YELLOW = "\x1B[33m";
const BOLDCYAN = "\x1B[1m\x1B[36m";
/**
* 导出预设配置CLI 版本,带交互)
* @param presetName 预设名称
* @param options 导出选项
*/
export async function exportPresetCli(
presetName: string,
options: ExportOptions = {}
): Promise<void> {
try {
console.log(`\n${BOLDCYAN}═══════════════════════════════════════════════${RESET}`);
console.log(`${BOLDCYAN} Preset Export${RESET}`);
console.log(`${BOLDCYAN}═══════════════════════════════════════════════${RESET}\n`);
// 1. 读取当前配置
const config = await readConfigFile();
// 2. 如果没有通过命令行提供,交互式询问元数据
if (!options.description) {
try {
options.description = await input({
message: 'Description (optional):',
default: '',
});
} catch {
// 用户取消,使用默认值
options.description = '';
}
}
if (!options.author) {
try {
options.author = await input({
message: 'Author (optional):',
default: '',
});
} catch {
options.author = '';
}
}
if (!options.tags) {
try {
const keywordsInput = await input({
message: 'Keywords (comma-separated, optional):',
default: '',
});
options.tags = keywordsInput || '';
} catch {
options.tags = '';
}
}
// 3. 调用核心导出功能
const result = await exportPresetCore(presetName, config, options);
// 4. 显示摘要
console.log(`\n${BOLDGREEN}✓ Preset exported successfully${RESET}\n`);
console.log(`${BOLDCYAN}Location:${RESET} ${result.outputPath}\n`);
console.log(`${BOLDCYAN}Summary:${RESET}`);
console.log(` - Providers: ${result.sanitizedConfig.Providers?.length || 0}`);
console.log(` - Router rules: ${Object.keys(result.sanitizedConfig.Router || {}).length}`);
if (!options.includeSensitive) {
console.log(` - Sensitive fields sanitized: ${YELLOW}${result.sanitizedCount}${RESET}`);
}
// 显示元数据
if (result.metadata.description) {
console.log(`\n${BOLDCYAN}Description:${RESET} ${result.metadata.description}`);
}
if (result.metadata.author) {
console.log(`${BOLDCYAN}Author:${RESET} ${result.metadata.author}`);
}
if (result.metadata.keywords && result.metadata.keywords.length > 0) {
console.log(`${BOLDCYAN}Keywords:${RESET} ${result.metadata.keywords.join(', ')}`);
}
// 显示分享提示
console.log(`\n${BOLDCYAN}To share this preset:${RESET}`);
console.log(` 1. Share the file: ${result.outputPath}`);
console.log(` 2. Upload to GitHub Gist or your repository`);
console.log(` 3. Others can install with: ${GREEN}ccr preset install <file>${RESET}\n`);
} catch (error: any) {
console.error(`\n${YELLOW}Error exporting preset:${RESET} ${error.message}`);
throw error;
}
}

View File

@@ -0,0 +1,12 @@
/**
* 预设功能 CLI 层
* 导出所有预设相关的功能和类型
*/
// 从 shared 包重新导出类型和核心功能
export * from '@CCR/shared';
// 导出 CLI 特定的功能(带交互)
export { exportPresetCli } from './export';
export { installPresetCli, applyPresetCli } from './install';
export { handlePresetCommand } from './commands';

View File

@@ -0,0 +1,301 @@
/**
* 预设安装功能 CLI 层
* 负责处理 CLI 交互,核心逻辑在 shared 包中
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { password, confirm } from '@inquirer/prompts';
import {
loadPreset as loadPresetShared,
validatePreset,
MergeStrategy,
getPresetDir,
readManifestFromDir,
manifestToPresetFile,
saveManifest,
extractPreset,
findPresetFile,
isPresetInstalled,
ManifestFile,
PresetFile
} from '@CCR/shared';
// 重新导出 loadPreset
export { loadPresetShared as loadPreset };
// ANSI 颜色代码
const RESET = "\x1B[0m";
const GREEN = "\x1B[32m";
const BOLDGREEN = "\x1B[1m\x1B[32m";
const YELLOW = "\x1B[33m";
const BOLDYELLOW = "\x1B[1m\x1B[33m";
const BOLDCYAN = "\x1B[1m\x1B[36m";
const DIM = "\x1B[2m";
/**
* 收集缺失的敏感信息
*/
async function collectSensitiveInputs(
preset: PresetFile
): Promise<Record<string, string>> {
const inputs: Record<string, string> = {};
if (!preset.requiredInputs || preset.requiredInputs.length === 0) {
return inputs;
}
console.log(`\n${BOLDYELLOW}This preset requires additional information:${RESET}\n`);
for (const inputField of preset.requiredInputs) {
let value: string;
// 尝试从环境变量获取
const envVarName = inputField.placeholder;
if (envVarName && process.env[envVarName]) {
const useEnv = await confirm({
message: `Found ${envVarName} in environment. Use it?`,
default: true,
});
if (useEnv) {
value = process.env[envVarName]!;
inputs[inputField.field] = value;
console.log(`${GREEN}${RESET} Using ${envVarName} from environment\n`);
continue;
}
}
// 提示用户输入
value = await password({
message: inputField.prompt || `Enter ${inputField.field}:`,
mask: '*',
});
if (!value || value.trim() === '') {
console.error(`${YELLOW}Error:${RESET} ${inputField.field} is required`);
process.exit(1);
}
// 验证输入
if (inputField.validator) {
const regex = typeof inputField.validator === 'string'
? new RegExp(inputField.validator)
: inputField.validator;
if (!regex.test(value)) {
console.error(`${YELLOW}Error:${RESET} Invalid format for ${inputField.field}`);
console.error(` Expected: ${inputField.validator}`);
process.exit(1);
}
}
inputs[inputField.field] = value;
console.log('');
}
return inputs;
}
/**
* 应用预设到配置
* @param presetName 预设名称
* @param preset 预设对象
*/
export async function applyPresetCli(
presetName: string,
preset: PresetFile
): Promise<void> {
try {
console.log(`${BOLDCYAN}Loading preset...${RESET} ${GREEN}${RESET}`);
// 验证预设
const validation = await validatePreset(preset);
if (validation.warnings.length > 0) {
console.log(`\n${YELLOW}Warnings:${RESET}`);
for (const warning of validation.warnings) {
console.log(` ${DIM}${RESET} ${warning}`);
}
}
if (!validation.valid) {
console.log(`\n${YELLOW}Validation errors:${RESET}`);
for (const error of validation.errors) {
console.log(` ${YELLOW}${RESET} ${error}`);
}
throw new Error('Invalid preset file');
}
console.log(`${BOLDCYAN}Validating preset...${RESET} ${GREEN}${RESET}`);
// 检查是否已经配置过通过检查manifest中是否已有敏感信息
const presetDir = getPresetDir(presetName);
try {
const existingManifest = await readManifestFromDir(presetDir);
// 检查是否已经配置了敏感信息例如api_key
const hasSecrets = existingManifest.Providers?.some((p: any) => p.api_key && p.api_key !== '');
if (hasSecrets) {
console.log(`\n${GREEN}${RESET} Preset already configured with secrets`);
console.log(`${DIM}You can use this preset with: ccr ${presetName}${RESET}\n`);
return;
}
} catch {
// manifest不存在继续配置流程
}
// 收集敏感信息
const sensitiveInputs = await collectSensitiveInputs(preset);
// 读取现有的manifest并更新
const manifest: ManifestFile = {
...(preset.metadata || {}),
...preset.config,
};
// 将secrets信息应用到manifest中
for (const [fieldPath, value] of Object.entries(sensitiveInputs)) {
const keys = fieldPath.split(/[.\[\]]+/).filter(k => k !== '');
let current = manifest as any;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!current[key]) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
if (preset.requiredInputs) {
manifest.requiredInputs = preset.requiredInputs;
}
// 保存到解压目录的manifest.json
await saveManifest(presetName, manifest);
// 显示摘要
console.log(`\n${BOLDGREEN}✓ Preset configured successfully!${RESET}\n`);
console.log(`${BOLDCYAN}Preset directory:${RESET} ${presetDir}`);
console.log(`${BOLDCYAN}Secrets configured:${RESET} ${Object.keys(sensitiveInputs).length}`);
if (preset.metadata?.description) {
console.log(`\n${BOLDCYAN}Description:${RESET} ${preset.metadata.description}`);
}
if (preset.metadata?.author) {
console.log(`${BOLDCYAN}Author:${RESET} ${preset.metadata.author}`);
}
const keywords = (preset.metadata as any).keywords;
if (keywords && keywords.length > 0) {
console.log(`${BOLDCYAN}Keywords:${RESET} ${keywords.join(', ')}`);
}
console.log(`\n${GREEN}Use this preset:${RESET} ccr ${presetName} "your prompt"`);
console.log(`${DIM}Note: Secrets are stored in the manifest file${RESET}\n`);
} catch (error: any) {
console.error(`\n${YELLOW}Error applying preset:${RESET} ${error.message}`);
throw error;
}
}
/**
* 安装预设(主入口)
*/
export async function installPresetCli(
source: string,
options: {
strategy?: MergeStrategy;
name?: string;
} = {}
): Promise<void> {
let tempFile: string | null = null;
try {
// 确定预设名称
let presetName = options.name;
let sourceZip: string;
let isReconfigure = false; // 是否是重新配置已安装的preset
// 判断source类型并获取ZIP文件路径
if (source.startsWith('http://') || source.startsWith('https://')) {
// URL下载到临时文件
if (!presetName) {
const urlParts = source.split('/');
const filename = urlParts[urlParts.length - 1];
presetName = filename.replace('.ccrsets', '');
}
// 这里直接从 shared 包导入的 downloadPresetToTemp 会返回临时文件
// 但我们会在 loadPreset 中自动清理,所以不需要在这里处理
const preset = await loadPreset(source);
if (!presetName) {
presetName = preset.metadata?.name || 'preset';
}
// 重新下载到临时文件以供 extractPreset 使用
// 由于 loadPreset 已经下载并删除了,这里需要特殊处理
throw new Error('URL installation not fully implemented yet');
} else if (source.includes('/') || source.includes('\\')) {
// 文件路径
if (!presetName) {
const filename = path.basename(source);
presetName = filename.replace('.ccrsets', '');
}
// 验证文件存在
try {
await fs.access(source);
} catch {
throw new Error(`Preset file not found: ${source}`);
}
sourceZip = source;
} else {
// 预设名称(不带路径)
presetName = source;
// 按优先级查找文件:当前目录 -> presets目录
const presetFile = await findPresetFile(source);
if (presetFile) {
sourceZip = presetFile;
} else {
// 检查是否已安装(目录存在)
if (await isPresetInstalled(source)) {
// 已安装,重新配置
isReconfigure = true;
} else {
// 都不存在,报错
throw new Error(`Preset '${source}' not found in current directory or presets directory.`);
}
}
}
if (isReconfigure) {
// 重新配置已安装的preset
console.log(`${BOLDCYAN}Reconfiguring preset:${RESET} ${presetName}\n`);
const presetDir = getPresetDir(presetName);
const manifest = await readManifestFromDir(presetDir);
const preset = manifestToPresetFile(manifest);
// 应用preset会询问敏感信息
await applyPresetCli(presetName, preset);
} else {
// 新安装:解压到目标目录
const targetDir = getPresetDir(presetName);
console.log(`${BOLDCYAN}Extracting preset to:${RESET} ${targetDir}`);
await extractPreset(sourceZip, targetDir);
console.log(`${GREEN}${RESET} Extracted successfully\n`);
// 从解压目录读取manifest
const manifest = await readManifestFromDir(targetDir);
const preset = manifestToPresetFile(manifest);
// 应用preset询问用户信息等
await applyPresetCli(presetName, preset);
}
} catch (error: any) {
console.error(`\n${YELLOW}Failed to install preset:${RESET} ${error.message}`);
process.exit(1);
}
}

View File

@@ -17,8 +17,10 @@
"author": "musistudio",
"license": "MIT",
"dependencies": {
"@fastify/multipart": "^9.0.0",
"@fastify/static": "^8.2.0",
"@musistudio/llms": "^1.0.51",
"adm-zip": "^0.5.16",
"dotenv": "^16.4.7",
"json5": "^2.2.3",
"lru-cache": "^11.2.2",
@@ -29,6 +31,7 @@
},
"devDependencies": {
"@CCR/shared": "workspace:*",
"@types/adm-zip": "^0.5.7",
"@types/node": "^24.0.15",
"esbuild": "^0.25.1",
"fastify": "^5.4.0",

View File

@@ -121,7 +121,7 @@ async function getServer(options: RunOptions = {}) {
}
}
const serverInstance = createServer({
const serverInstance = await createServer({
jsonPath: CONFIG_FILE,
initialConfig: {
// ...config,
@@ -370,11 +370,11 @@ async function getServer(options: RunOptions = {}) {
// Add global error handlers to prevent the service from crashing
process.on("uncaughtException", (err) => {
serverInstance.logger.error("Uncaught exception:", err);
serverInstance.app.log.error("Uncaught exception:", err);
});
process.on("unhandledRejection", (reason, promise) => {
serverInstance.logger.error("Unhandled rejection at:", promise, "reason:", reason);
serverInstance.app.log.error("Unhandled rejection at:", promise, "reason:", reason);
});
return serverInstance;

View File

@@ -2,27 +2,53 @@ import Server from "@musistudio/llms";
import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
import { join } from "path";
import fastifyStatic from "@fastify/static";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, rmSync } from "fs";
import { homedir } from "os";
import { calculateTokenCount } from "./utils/router";
import {
getPresetDir,
readManifestFromDir,
manifestToPresetFile,
extractPreset,
validatePreset,
loadPreset,
saveManifest,
isPresetInstalled,
downloadPresetToTemp,
getTempDir,
HOME_DIR,
type PresetFile,
type ManifestFile,
type PresetMetadata,
MergeStrategy
} from "@CCR/shared";
export const createServer = (config: any): any => {
export const createServer = async (config: any): Promise<any> => {
const server = new Server(config);
const app = server.app;
server.app.post("/v1/messages/count_tokens", async (req: any, reply: any) => {
// Register multipart plugin for file uploads (dynamic import)
const fastifyMultipart = await import('@fastify/multipart');
app.register(fastifyMultipart.default, {
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
},
});
app.post("/v1/messages/count_tokens", async (req: any, reply: any) => {
const {messages, tools, system} = req.body;
const tokenCount = calculateTokenCount(messages, system, tools);
return { "input_tokens": tokenCount }
});
// Add endpoint to read config.json with access control
server.app.get("/api/config", async (req: any, reply: any) => {
app.get("/api/config", async (req: any, reply: any) => {
return await readConfigFile();
});
server.app.get("/api/transformers", async (req: any, reply: any) => {
app.get("/api/transformers", async (req: any, reply: any) => {
const transformers =
(server.app as any)._server!.transformerService.getAllTransformers();
(app as any)._server!.transformerService.getAllTransformers();
const transformerList = Array.from(transformers.entries()).map(
([name, transformer]: any) => ({
name,
@@ -33,7 +59,7 @@ export const createServer = (config: any): any => {
});
// Add endpoint to save config.json with access control
server.app.post("/api/config", async (req: any, reply: any) => {
app.post("/api/config", async (req: any, reply: any) => {
const newConfig = req.body;
// Backup existing config file if it exists
@@ -47,19 +73,19 @@ export const createServer = (config: any): any => {
});
// Register static file serving with caching
server.app.register(fastifyStatic, {
app.register(fastifyStatic, {
root: join(__dirname, "..", "dist"),
prefix: "/ui/",
maxAge: "1h",
});
// Redirect /ui to /ui/ for proper static file serving
server.app.get("/ui", async (_: any, reply: any) => {
app.get("/ui", async (_: any, reply: any) => {
return reply.redirect("/ui/");
});
// 获取日志文件列表端点
server.app.get("/api/logs/files", async (req: any, reply: any) => {
app.get("/api/logs/files", async (req: any, reply: any) => {
try {
const logDir = join(homedir(), ".claude-code-router", "logs");
const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = [];
@@ -93,7 +119,7 @@ export const createServer = (config: any): any => {
});
// 获取日志内容端点
server.app.get("/api/logs", async (req: any, reply: any) => {
app.get("/api/logs", async (req: any, reply: any) => {
try {
const filePath = (req.query as any).file as string;
let logFilePath: string;
@@ -121,7 +147,7 @@ export const createServer = (config: any): any => {
});
// 清除日志内容端点
server.app.delete("/api/logs", async (req: any, reply: any) => {
app.delete("/api/logs", async (req: any, reply: any) => {
try {
const filePath = (req.query as any).file as string;
let logFilePath: string;
@@ -145,5 +171,252 @@ export const createServer = (config: any): any => {
}
});
// ========== Preset 相关 API ==========
// 获取预设列表
app.get("/api/presets", async (req: any, reply: any) => {
try {
const presetsDir = join(HOME_DIR, "presets");
if (!existsSync(presetsDir)) {
return { presets: [] };
}
const entries = readdirSync(presetsDir, { withFileTypes: true });
const presetDirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
const presets: Array<PresetMetadata & { installed: boolean; id: string }> = [];
for (const dirName of presetDirs) {
const presetDir = join(presetsDir, dirName);
try {
const manifestPath = join(presetDir, "manifest.json");
const content = readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(content);
// 提取 metadata 字段
const { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE, requiredInputs, ...metadata } = manifest;
presets.push({
id: dirName, // 目录名作为唯一标识
name: metadata.name || dirName,
version: metadata.version || '1.0.0',
description: metadata.description,
author: metadata.author,
homepage: metadata.homepage,
repository: metadata.repository,
license: metadata.license,
keywords: metadata.keywords,
ccrVersion: metadata.ccrVersion,
source: metadata.source,
sourceType: metadata.sourceType,
checksum: metadata.checksum,
installed: true,
});
} catch (error) {
console.error(`Failed to read preset ${dirName}:`, error);
}
}
return { presets };
} catch (error) {
console.error("Failed to get presets:", error);
reply.status(500).send({ error: "Failed to get presets" });
}
});
// 获取预设详情
app.get("/api/presets/:name", async (req: any, reply: any) => {
try {
const { name } = req.params;
const presetDir = getPresetDir(name);
if (!existsSync(presetDir)) {
reply.status(404).send({ error: "Preset not found" });
return;
}
const manifest = await readManifestFromDir(presetDir);
const preset = manifestToPresetFile(manifest);
return preset;
} catch (error: any) {
console.error("Failed to get preset:", error);
reply.status(500).send({ error: error.message || "Failed to get preset" });
}
});
// 上传并安装预设(支持文件上传)
app.post("/api/presets/install", async (req: any, reply: any) => {
try {
const { source, name, url } = req.body;
// 如果提供了 URL从 URL 下载
if (url) {
const tempFile = await downloadPresetToTemp(url);
const preset = await loadPresetFromZip(tempFile);
// 确定预设名称
const presetName = name || preset.metadata?.name || `preset-${Date.now()}`;
// 检查是否已安装
if (await isPresetInstalled(presetName)) {
reply.status(409).send({ error: "Preset already installed" });
return;
}
// 解压到目标目录
const targetDir = getPresetDir(presetName);
await extractPreset(tempFile, targetDir);
// 清理临时文件
unlinkSync(tempFile);
return {
success: true,
presetName,
preset: {
...preset.metadata,
installed: true,
}
};
}
// 如果没有 URL需要处理文件上传使用 multipart/form-data
// 这部分需要在客户端使用 FormData 上传
reply.status(400).send({ error: "Please provide a URL or upload a file" });
} catch (error: any) {
console.error("Failed to install preset:", error);
reply.status(500).send({ error: error.message || "Failed to install preset" });
}
});
// 上传预设文件multipart/form-data
app.post("/api/presets/upload", async (req: any, reply: any) => {
try {
const data = await req.file();
if (!data) {
reply.status(400).send({ error: "No file uploaded" });
return;
}
const tempDir = getTempDir();
mkdirSync(tempDir, { recursive: true });
const tempFile = join(tempDir, `preset-${Date.now()}.ccrsets`);
// 保存上传的文件到临时位置
const buffer = await data.toBuffer();
writeFileSync(tempFile, buffer);
// 加载预设
const preset = await loadPresetFromZip(tempFile);
// 确定预设名称
const presetName = data.fields.name?.value || preset.metadata?.name || `preset-${Date.now()}`;
// 检查是否已安装
if (await isPresetInstalled(presetName)) {
unlinkSync(tempFile);
reply.status(409).send({ error: "Preset already installed" });
return;
}
// 解压到目标目录
const targetDir = getPresetDir(presetName);
await extractPreset(tempFile, targetDir);
// 清理临时文件
unlinkSync(tempFile);
return {
success: true,
presetName,
preset: {
...preset.metadata,
installed: true,
}
};
} catch (error: any) {
console.error("Failed to upload preset:", error);
reply.status(500).send({ error: error.message || "Failed to upload preset" });
}
});
// 应用预设(配置敏感信息)
app.post("/api/presets/:name/apply", async (req: any, reply: any) => {
try {
const { name } = req.params;
const { secrets } = req.body;
const presetDir = getPresetDir(name);
if (!existsSync(presetDir)) {
reply.status(404).send({ error: "Preset not found" });
return;
}
// 读取现有 manifest
const manifest = await readManifestFromDir(presetDir);
// 将 secrets 信息应用到 manifest 中
if (secrets) {
for (const [fieldPath, value] of Object.entries(secrets)) {
const keys = fieldPath.split(/[.\[\]]+/).filter(k => k !== '');
let current = manifest as any;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!current[key]) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
}
// 保存更新后的 manifest
await saveManifest(name, manifest);
return { success: true, message: "Preset applied successfully" };
} catch (error: any) {
console.error("Failed to apply preset:", error);
reply.status(500).send({ error: error.message || "Failed to apply preset" });
}
});
// 删除预设
app.delete("/api/presets/:name", async (req: any, reply: any) => {
try {
const { name } = req.params;
const presetDir = getPresetDir(name);
if (!existsSync(presetDir)) {
reply.status(404).send({ error: "Preset not found" });
return;
}
// 递归删除整个目录
rmSync(presetDir, { recursive: true, force: true });
return { success: true, message: "Preset deleted successfully" };
} catch (error: any) {
console.error("Failed to delete preset:", error);
reply.status(500).send({ error: error.message || "Failed to delete preset" });
}
});
// 辅助函数:从 ZIP 加载预设
async function loadPresetFromZip(zipFile: string): Promise<PresetFile> {
const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(zipFile);
const entry = zip.getEntry('manifest.json');
if (!entry) {
throw new Error('Invalid preset file: manifest.json not found');
}
const manifest = JSON.parse(entry.getData().toString('utf-8')) as ManifestFile;
return manifestToPresetFile(manifest);
}
return server;
};

View File

@@ -15,7 +15,14 @@
],
"author": "musistudio",
"license": "MIT",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"json5": "^2.2.3"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/archiver": "^7.0.0",
"@types/node": "^24.0.15",
"esbuild": "^0.25.1",
"typescript": "^5.8.2"

View File

@@ -1 +1,9 @@
export * from "./constants";
// Export preset-related functionality
export * from './preset/types';
export * from './preset/sensitiveFields';
export * from './preset/merge';
export * from './preset/install';
export * from './preset/export';
export * from './preset/readPreset';

View File

@@ -0,0 +1,134 @@
/**
* 预设导出核心功能
* 注意:这个模块不包含 CLI 交互逻辑,交互逻辑由调用者提供
*/
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import archiver from 'archiver';
import { sanitizeConfig } from './sensitiveFields';
import { PresetFile, PresetMetadata, ManifestFile } from './types';
import { HOME_DIR } from '../constants';
/**
* 导出选项
*/
export interface ExportOptions {
output?: string;
includeSensitive?: boolean;
description?: string;
author?: string;
tags?: string;
}
/**
* 导出结果
*/
export interface ExportResult {
outputPath: string;
sanitizedConfig: any;
metadata: PresetMetadata;
requiredInputs: any[];
sanitizedCount: number;
}
/**
* 创建 manifest 对象
* @param presetName 预设名称
* @param config 配置对象
* @param sanitizedConfig 脱敏后的配置
* @param options 导出选项
*/
export function createManifest(
presetName: string,
config: any,
sanitizedConfig: any,
options: ExportOptions,
requiredInputs: any[] = []
): ManifestFile {
const metadata: PresetMetadata = {
name: presetName,
version: '1.0.0',
description: options.description,
author: options.author,
keywords: options.tags ? options.tags.split(',').map(t => t.trim()) : undefined,
};
return {
...metadata,
...sanitizedConfig,
requiredInputs: options.includeSensitive ? undefined : requiredInputs,
};
}
/**
* 导出预设配置
* @param presetName 预设名称
* @param config 当前配置
* @param options 导出选项
* @returns 导出结果
*/
export async function exportPreset(
presetName: string,
config: any,
options: ExportOptions = {}
): Promise<ExportResult> {
// 1. 收集元数据
const metadata: PresetMetadata = {
name: presetName,
version: '1.0.0',
description: options.description,
author: options.author,
keywords: options.tags ? options.tags.split(',').map(t => t.trim()) : undefined,
};
// 2. 脱敏配置
const { sanitizedConfig, requiredInputs, sanitizedCount } = await sanitizeConfig(config);
// 3. 生成manifest.json扁平化结构
const manifest: ManifestFile = {
...metadata,
...sanitizedConfig,
requiredInputs: options.includeSensitive ? undefined : requiredInputs,
};
// 4. 确定输出路径
const presetsDir = path.join(HOME_DIR, 'presets');
// 确保预设目录存在
await fs.mkdir(presetsDir, { recursive: true });
const outputPath = options.output || path.join(presetsDir, `${presetName}.ccrsets`);
// 5. 创建压缩包
const output = fsSync.createWriteStream(outputPath);
const archive = archiver('zip', {
zlib: { level: 9 } // 最高压缩级别
});
return new Promise<ExportResult>((resolve, reject) => {
output.on('close', () => {
resolve({
outputPath,
sanitizedConfig,
metadata,
requiredInputs,
sanitizedCount,
});
});
archive.on('error', (err: Error) => {
reject(err);
});
// 连接输出流
archive.pipe(output);
// 添加manifest.json到压缩包
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
// 完成压缩
archive.finalize();
});
}

View File

@@ -0,0 +1,239 @@
/**
* 预设安装核心功能
* 注意:这个模块不包含 CLI 交互逻辑,交互逻辑由调用者提供
*/
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import JSON5 from 'json5';
import AdmZip from 'adm-zip';
import { PresetFile, MergeStrategy, RequiredInput, ManifestFile } from './types';
import { HOME_DIR } from '../constants';
/**
* 获取预设目录的完整路径
* @param presetName 预设名称
*/
export function getPresetDir(presetName: string): string {
return path.join(HOME_DIR, 'presets', presetName);
}
/**
* 获取临时目录路径
*/
export function getTempDir(): string {
return path.join(HOME_DIR, 'temp');
}
/**
* 解压预设文件到目标目录
* @param sourceZip 源ZIP文件路径
* @param targetDir 目标目录
*/
export async function extractPreset(sourceZip: string, targetDir: string): Promise<void> {
// 检查目标目录是否已存在
try {
await fs.access(targetDir);
throw new Error(`Preset directory already exists: ${path.basename(targetDir)}`);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
// ENOENT 表示目录不存在,可以继续
}
// 创建目标目录
await fs.mkdir(targetDir, { recursive: true });
// 解压文件
const zip = new AdmZip(sourceZip);
zip.extractAllTo(targetDir, true);
}
/**
* 从解压目录读取manifest
* @param presetDir 预设目录路径
*/
export async function readManifestFromDir(presetDir: string): Promise<ManifestFile> {
const manifestPath = path.join(presetDir, 'manifest.json');
const content = await fs.readFile(manifestPath, 'utf-8');
return JSON5.parse(content) as ManifestFile;
}
/**
* 将manifest转换为PresetFile格式
*/
export function manifestToPresetFile(manifest: ManifestFile): PresetFile {
const { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE, requiredInputs, ...metadata } = manifest;
return {
metadata,
config: { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE },
requiredInputs,
};
}
/**
* 下载预设文件到临时位置
* @param url 下载URL
* @returns 临时文件路径
*/
export async function downloadPresetToTemp(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download preset: ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
// 创建临时文件
const tempDir = getTempDir();
await fs.mkdir(tempDir, { recursive: true });
const tempFile = path.join(tempDir, `preset-${Date.now()}.ccrsets`);
await fs.writeFile(tempFile, Buffer.from(buffer));
return tempFile;
}
/**
* 从本地ZIP文件加载预设
* @param zipFile ZIP文件路径
* @returns PresetFile
*/
export async function loadPresetFromZip(zipFile: string): Promise<PresetFile> {
const zip = new AdmZip(zipFile);
const entry = zip.getEntry('manifest.json');
if (!entry) {
throw new Error('Invalid preset file: manifest.json not found');
}
const manifest = JSON5.parse(entry.getData().toString('utf-8')) as ManifestFile;
return manifestToPresetFile(manifest);
}
/**
* 加载预设文件
* @param source 预设来源文件路径、URL 或预设名称)
*/
export async function loadPreset(source: string): Promise<PresetFile> {
// 判断是否是 URL
if (source.startsWith('http://') || source.startsWith('https://')) {
const tempFile = await downloadPresetToTemp(source);
const preset = await loadPresetFromZip(tempFile);
// 删除临时文件
await fs.unlink(tempFile).catch(() => {});
return preset;
}
// 判断是否是绝对路径或相对路径(包含 / 或 \
if (source.includes('/') || source.includes('\\')) {
// 文件路径
return await loadPresetFromZip(source);
}
// 否则作为预设名称处理(从解压目录读取)
const presetDir = getPresetDir(source);
const manifest = await readManifestFromDir(presetDir);
return manifestToPresetFile(manifest);
}
/**
* 验证预设文件
*/
export async function validatePreset(preset: PresetFile): Promise<{
valid: boolean;
errors: string[];
warnings: string[];
}> {
const errors: string[] = [];
const warnings: string[] = [];
// 验证元数据
if (!preset.metadata) {
warnings.push('Missing metadata section');
} else {
if (!preset.metadata.name) {
errors.push('Missing preset name in metadata');
}
if (!preset.metadata.version) {
warnings.push('Missing version in metadata');
}
}
// 验证配置部分
if (!preset.config) {
errors.push('Missing config section');
}
// 验证 Providers
if (preset.config.Providers) {
for (const provider of preset.config.Providers) {
if (!provider.name) {
errors.push('Provider missing name field');
}
if (!provider.api_base_url) {
errors.push(`Provider "${provider.name}" missing api_base_url`);
}
if (!provider.models || provider.models.length === 0) {
warnings.push(`Provider "${provider.name}" has no models`);
}
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* 保存 manifest 到预设目录
* @param presetName 预设名称
* @param manifest manifest 对象
*/
export async function saveManifest(presetName: string, manifest: ManifestFile): Promise<void> {
const presetDir = getPresetDir(presetName);
const manifestPath = path.join(presetDir, 'manifest.json');
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
}
/**
* 查找预设文件
* @param source 预设来源
* @returns 文件路径或 null
*/
export async function findPresetFile(source: string): Promise<string | null> {
// 当前目录文件
const currentDirFile = path.join(process.cwd(), `${source}.ccrsets`);
// presets 目录文件
const presetsDirFile = path.join(HOME_DIR, 'presets', `${source}.ccrsets`);
// 检查当前目录
try {
await fs.access(currentDirFile);
return currentDirFile;
} catch {
// 检查presets目录
try {
await fs.access(presetsDirFile);
return presetsDirFile;
} catch {
return null;
}
}
}
/**
* 检查预设是否已安装
* @param presetName 预设名称
*/
export async function isPresetInstalled(presetName: string): Promise<boolean> {
const presetDir = getPresetDir(presetName);
try {
await fs.access(presetDir);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,280 @@
/**
* 配置合并策略
*/
import { MergeStrategy, ProviderConfig, RouterConfig, TransformerConfig, ProviderConflictAction } from './types';
/**
* 合并 Provider 配置
*/
async function mergeProviders(
existing: ProviderConfig[],
incoming: ProviderConfig[],
strategy: MergeStrategy,
onProviderConflict?: (providerName: string) => Promise<ProviderConflictAction>
): Promise<ProviderConfig[]> {
const result = [...existing];
const existingNames = new Set(existing.map(p => p.name));
for (const provider of incoming) {
if (existingNames.has(provider.name)) {
// Provider 已存在,需要处理冲突
let action: ProviderConflictAction;
if (strategy === MergeStrategy.ASK && onProviderConflict) {
action = await onProviderConflict(provider.name);
} else if (strategy === MergeStrategy.OVERWRITE) {
action = 'overwrite';
} else if (strategy === MergeStrategy.MERGE) {
action = 'merge';
} else {
action = 'skip';
}
switch (action) {
case 'keep':
// 保留现有,不做任何操作
break;
case 'overwrite':
const index = result.findIndex(p => p.name === provider.name);
result[index] = provider;
break;
case 'merge':
const existingProvider = result.find(p => p.name === provider.name)!;
// 合并模型列表,去重
const mergedModels = [...new Set([
...existingProvider.models,
...provider.models,
])];
existingProvider.models = mergedModels;
// 合并 transformer 配置
if (provider.transformer) {
if (!existingProvider.transformer) {
existingProvider.transformer = provider.transformer;
} else {
// 合并 transformer.use
if (provider.transformer.use && existingProvider.transformer.use) {
const mergedTransformers = [...new Set([
...existingProvider.transformer.use,
...provider.transformer.use,
])];
existingProvider.transformer.use = mergedTransformers as any;
}
}
}
break;
case 'skip':
// 跳过,不做任何操作
break;
}
} else {
// 新 Provider直接添加
result.push(provider);
}
}
return result;
}
/**
* 合并 Router 配置
*/
async function mergeRouter(
existing: RouterConfig,
incoming: RouterConfig,
strategy: MergeStrategy,
onRouterConflict?: (key: string, existingValue: any, newValue: any) => Promise<boolean>
): Promise<RouterConfig> {
const result = { ...existing };
for (const [key, value] of Object.entries(incoming)) {
if (value === undefined || value === null) {
continue;
}
const existingValue = result[key];
if (existingValue === undefined || existingValue === null) {
// 现有配置中没有这个路由规则,直接添加
result[key] = value;
} else {
// 存在冲突
if (strategy === MergeStrategy.ASK && onRouterConflict) {
const shouldOverwrite = await onRouterConflict(key, existingValue, value);
if (shouldOverwrite) {
result[key] = value;
}
} else if (strategy === MergeStrategy.OVERWRITE) {
result[key] = value;
} else if (strategy === MergeStrategy.MERGE) {
// 对于 Routermerge 策略等同于 skip保留现有值
// 或者可以询问用户
}
// skip 策略:保留现有值,不做任何操作
}
}
return result;
}
/**
* 合并 Transformer 配置
*/
async function mergeTransformers(
existing: TransformerConfig[],
incoming: TransformerConfig[],
strategy: MergeStrategy,
onTransformerConflict?: (transformerPath: string) => Promise<'keep' | 'overwrite' | 'skip'>
): Promise<TransformerConfig[]> {
if (!existing || existing.length === 0) {
return incoming;
}
if (!incoming || incoming.length === 0) {
return existing;
}
// Transformer 合并逻辑:按路径匹配
const result = [...existing];
const existingPaths = new Set(existing.map(t => t.path));
for (const transformer of incoming) {
if (!transformer.path) {
// 没有 path 的 transformer直接添加
result.push(transformer);
continue;
}
if (existingPaths.has(transformer.path)) {
// 已存在相同 path 的 transformer
if (strategy === MergeStrategy.ASK && onTransformerConflict) {
const action = await onTransformerConflict(transformer.path);
if (action === 'overwrite') {
const index = result.findIndex(t => t.path === transformer.path);
result[index] = transformer;
}
// keep 和 skip 都不做操作
} else if (strategy === MergeStrategy.OVERWRITE) {
const index = result.findIndex(t => t.path === transformer.path);
result[index] = transformer;
}
// merge 和 skip 策略:保留现有
} else {
// 新 transformer直接添加
result.push(transformer);
}
}
return result;
}
/**
* 合并其他顶级配置
*/
async function mergeOtherConfig(
existing: any,
incoming: any,
strategy: MergeStrategy,
onConfigConflict?: (key: string) => Promise<boolean>,
excludeKeys: string[] = ['Providers', 'Router', 'transformers']
): Promise<any> {
const result = { ...existing };
for (const [key, value] of Object.entries(incoming)) {
if (excludeKeys.includes(key)) {
continue;
}
if (value === undefined || value === null) {
continue;
}
const existingValue = result[key];
if (existingValue === undefined || existingValue === null) {
// 现有配置中没有这个字段,直接添加
result[key] = value;
} else {
// 存在冲突
if (strategy === MergeStrategy.ASK && onConfigConflict) {
const shouldOverwrite = await onConfigConflict(key);
if (shouldOverwrite) {
result[key] = value;
}
} else if (strategy === MergeStrategy.OVERWRITE) {
result[key] = value;
}
// merge 和 skip 策略:保留现有值
}
}
return result;
}
/**
* 合并交互回调接口
*/
export interface MergeCallbacks {
onProviderConflict?: (providerName: string) => Promise<ProviderConflictAction>;
onRouterConflict?: (key: string, existingValue: any, newValue: any) => Promise<boolean>;
onTransformerConflict?: (transformerPath: string) => Promise<'keep' | 'overwrite' | 'skip'>;
onConfigConflict?: (key: string) => Promise<boolean>;
}
/**
* 主配置合并函数
* @param baseConfig 基础配置(现有配置)
* @param presetConfig 预设配置
* @param strategy 合并策略
* @param callbacks 交互式回调函数
* @returns 合并后的配置
*/
export async function mergeConfig(
baseConfig: any,
presetConfig: any,
strategy: MergeStrategy = MergeStrategy.ASK,
callbacks?: MergeCallbacks
): Promise<any> {
const result = { ...baseConfig };
// 合并 Providers
if (presetConfig.Providers) {
result.Providers = await mergeProviders(
result.Providers || [],
presetConfig.Providers,
strategy,
callbacks?.onProviderConflict
);
}
// 合并 Router
if (presetConfig.Router) {
result.Router = await mergeRouter(
result.Router || {},
presetConfig.Router,
strategy,
callbacks?.onRouterConflict
);
}
// 合并 transformers
if (presetConfig.transformers) {
result.transformers = await mergeTransformers(
result.transformers || [],
presetConfig.transformers,
strategy,
callbacks?.onTransformerConflict
);
}
// 合并其他配置
const otherConfig = await mergeOtherConfig(
result,
presetConfig,
strategy,
callbacks?.onConfigConflict
);
return otherConfig;
}

View File

@@ -0,0 +1,30 @@
/**
* 读取预设配置文件
* 用于 CLI 快速读取预设配置
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import JSON5 from 'json5';
import { HOME_DIR } from '../constants';
/**
* 读取 preset 配置文件
* @param name preset 名称
* @returns preset 配置对象,如果文件不存在则返回 null
*/
export async function readPresetFile(name: string): Promise<any | null> {
try {
const presetDir = path.join(HOME_DIR, 'presets', name);
const manifestPath = path.join(presetDir, 'manifest.json');
const manifest = JSON5.parse(await fs.readFile(manifestPath, 'utf-8'));
// manifest已经是扁平化结构直接返回
return manifest;
} catch (error: any) {
if (error.code === 'ENOENT') {
return null;
}
console.error(`Failed to read preset file: ${error.message}`);
return null;
}
}

View File

@@ -0,0 +1,249 @@
/**
* 敏感字段识别和脱敏功能
*/
import { RequiredInput, SanitizeResult } from './types';
// 敏感字段模式列表
const SENSITIVE_PATTERNS = [
'api_key', 'apikey', 'apiKey', 'APIKEY',
'api_secret', 'apisecret', 'apiSecret',
'secret', 'SECRET',
'token', 'TOKEN', 'auth_token',
'password', 'PASSWORD', 'passwd',
'private_key', 'privateKey',
'access_key', 'accessKey',
];
// 环境变量占位符正则
const ENV_VAR_REGEX = /^\$\{?[A-Z_][A-Z0-9_]*\}?$/;
/**
* 检查字段名是否为敏感字段
*/
function isSensitiveField(fieldName: string): boolean {
const lowerFieldName = fieldName.toLowerCase();
return SENSITIVE_PATTERNS.some(pattern =>
lowerFieldName.includes(pattern.toLowerCase())
);
}
/**
* 生成环境变量名称
* @param fieldType 字段类型 (provider, transformer, global)
* @param entityName 实体名称 (如 provider name)
* @param fieldName 字段名称
*/
export function generateEnvVarName(
fieldType: 'provider' | 'transformer' | 'global',
entityName: string,
fieldName: string
): string {
// 生成大写的环境变量名
// 例如: DEEPSEEK_API_KEY, CUSTOM_TRANSFORMER_SECRET
const prefix = entityName.toUpperCase().replace(/[^A-Z0-9]/g, '_');
const field = fieldName.toUpperCase().replace(/[^A-Z0-9]/g, '_');
// 如果前缀和字段名相同(如 API_KEY避免重复
if (prefix === field) {
return prefix;
}
return `${prefix}_${field}`;
}
/**
* 检查值是否已经是环境变量占位符
*/
function isEnvPlaceholder(value: any): boolean {
if (typeof value !== 'string') {
return false;
}
return ENV_VAR_REGEX.test(value.trim());
}
/**
* 从环境变量占位符中提取变量名
* @param value 环境变量值(如 $VAR 或 ${VAR}
*/
function extractEnvVarName(value: string): string | null {
const trimmed = value.trim();
// 匹配 ${VAR_NAME} 格式
const bracedMatch = trimmed.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/);
if (bracedMatch) {
return bracedMatch[1];
}
// 匹配 $VAR_NAME 格式
const unbracedMatch = trimmed.match(/^\$([A-Z_][A-Z0-9_]*)$/);
if (unbracedMatch) {
return unbracedMatch[1];
}
return null;
}
/**
* 递归遍历对象,识别和脱敏敏感字段
* @param config 配置对象
* @param path 当前字段路径
* @param requiredInputs 必需输入数组(累积)
* @param sanitizedCount 脱敏字段计数
*/
function sanitizeObject(
config: any,
path: string = '',
requiredInputs: RequiredInput[] = [],
sanitizedCount: number = 0
): { sanitized: any; requiredInputs: RequiredInput[]; count: number } {
if (!config || typeof config !== 'object') {
return { sanitized: config, requiredInputs, count: sanitizedCount };
}
if (Array.isArray(config)) {
const sanitizedArray: any[] = [];
for (let i = 0; i < config.length; i++) {
const result = sanitizeObject(
config[i],
path ? `${path}[${i}]` : `[${i}]`,
requiredInputs,
sanitizedCount
);
sanitizedArray.push(result.sanitized);
requiredInputs = result.requiredInputs;
sanitizedCount = result.count;
}
return { sanitized: sanitizedArray, requiredInputs, count: sanitizedCount };
}
const sanitizedObj: any = {};
for (const [key, value] of Object.entries(config)) {
const currentPath = path ? `${path}.${key}` : key;
// 检查是否是敏感字段
if (isSensitiveField(key) && typeof value === 'string') {
// 如果值已经是环境变量,保持不变
if (isEnvPlaceholder(value)) {
sanitizedObj[key] = value;
// 仍然需要记录为必需输入,但使用已有环境变量
const envVarName = extractEnvVarName(value);
if (envVarName && !requiredInputs.some(input => input.field === currentPath)) {
requiredInputs.push({
field: currentPath,
prompt: `Enter ${key}`,
placeholder: envVarName,
});
}
} else {
// 脱敏:替换为环境变量占位符
// 尝试从路径中推断实体名称
let entityName = 'CONFIG';
const pathParts = currentPath.split('.');
// 如果路径包含 Providers 或 transformers尝试提取实体名称
for (let i = 0; i < pathParts.length; i++) {
if (pathParts[i] === 'Providers' || pathParts[i] === 'transformers') {
// 查找 name 字段
if (i + 1 < pathParts.length && pathParts[i + 1].match(/^\d+$/)) {
// 这是数组索引,查找同级的 name 字段
const parentPath = pathParts.slice(0, i + 2).join('.');
// 在当前上下文中查找 name
const context = config;
if (context.name) {
entityName = context.name;
}
}
break;
}
}
const envVarName = generateEnvVarName('global', entityName, key);
sanitizedObj[key] = `\${${envVarName}}`;
// 记录为必需输入
requiredInputs.push({
field: currentPath,
prompt: `Enter ${key}`,
placeholder: envVarName,
});
sanitizedCount++;
}
} else if (typeof value === 'object' && value !== null) {
// 递归处理嵌套对象
const result = sanitizeObject(value, currentPath, requiredInputs, sanitizedCount);
sanitizedObj[key] = result.sanitized;
requiredInputs = result.requiredInputs;
sanitizedCount = result.count;
} else {
// 保留原始值
sanitizedObj[key] = value;
}
}
return { sanitized: sanitizedObj, requiredInputs, count: sanitizedCount };
}
/**
* 脱敏配置对象
* @param config 原始配置
* @returns 脱敏结果
*/
export async function sanitizeConfig(config: any): Promise<SanitizeResult> {
// 深拷贝配置,避免修改原始对象
const configCopy = JSON.parse(JSON.stringify(config));
const result = sanitizeObject(configCopy);
return {
sanitizedConfig: result.sanitized,
requiredInputs: result.requiredInputs,
sanitizedCount: result.count,
};
}
/**
* 填充敏感信息到配置中
* @param config 预设配置(包含环境变量占位符)
* @param inputs 用户输入的敏感信息
* @returns 填充后的配置
*/
export function fillSensitiveInputs(config: any, inputs: Record<string, string>): any {
const configCopy = JSON.parse(JSON.stringify(config));
function fillObject(obj: any, path: string = ''): any {
if (!obj || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item, index) =>
fillObject(item, path ? `${path}[${index}]` : `[${index}]`)
);
}
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
const currentPath = path ? `${path}.${key}` : key;
if (typeof value === 'string' && isEnvPlaceholder(value)) {
// 查找是否有用户输入
const input = inputs[currentPath];
if (input) {
result[key] = input;
} else {
result[key] = value;
}
} else if (typeof value === 'object' && value !== null) {
result[key] = fillObject(value, currentPath);
} else {
result[key] = value;
}
}
return result;
}
return fillObject(configCopy);
}

View File

@@ -0,0 +1,138 @@
/**
* 预设功能的类型定义
*/
// 敏感字段输入要求
export interface RequiredInput {
field: string; // 字段路径 (如 "Providers[0].api_key")
prompt?: string; // 提示信息
placeholder?: string; // 占位符环境变量名
defaultValue?: string; // 默认值
validator?: RegExp | string; // 验证规则
}
// Provider 配置
export interface ProviderConfig {
name: string;
api_base_url: string;
api_key: string;
models: string[];
transformer?: any;
[key: string]: any;
}
// Router 配置
export interface RouterConfig {
default?: string;
background?: string;
think?: string;
longContext?: string;
longContextThreshold?: number;
webSearch?: string;
image?: string;
[key: string]: string | number | undefined;
}
// Transformer 配置
export interface TransformerConfig {
path?: string;
use: Array<string | [string, any]>;
options?: any;
[key: string]: any;
}
// 预设元数据扁平化结构用于manifest.json
export interface PresetMetadata {
name: string; // 预设名称
version: string; // 版本号 (semver)
description?: string; // 描述
author?: string; // 作者
homepage?: string; // 主页
repository?: string; // 源码仓库
license?: string; // 许可证
keywords?: string[]; // 关键词原tags
ccrVersion?: string; // 兼容的 CCR 版本
source?: string; // 预设来源 URL
sourceType?: 'local' | 'gist' | 'registry';
checksum?: string; // 预设内容校验和
}
// 预设配置部分
export interface PresetConfigSection {
Providers?: ProviderConfig[];
Router?: RouterConfig;
transformers?: TransformerConfig[];
PORT?: number;
HOST?: string;
API_TIMEOUT_MS?: number;
PROXY_URL?: string;
LOG?: boolean;
LOG_LEVEL?: string;
StatusLine?: any;
NON_INTERACTIVE_MODE?: boolean;
[key: string]: any;
}
// 完整的预设文件格式
export interface PresetFile {
metadata?: PresetMetadata;
config: PresetConfigSection;
secrets?: {
// 敏感信息存储,格式:字段路径 -> 值
// 例如:{ "Providers[0].api_key": "sk-xxx", "APIKEY": "my-secret" }
[fieldPath: string]: string;
};
requiredInputs?: RequiredInput[];
}
// manifest.json 格式(压缩包内的文件)
export interface ManifestFile extends PresetMetadata, PresetConfigSection {
requiredInputs?: RequiredInput[];
}
// 在线预设索引条目
export interface PresetIndexEntry {
id: string; // 唯一标识
name: string; // 显示名称
description?: string; // 简短描述
version: string; // 最新版本
author?: string; // 作者
downloads?: number; // 下载次数
stars?: number; // 点赞数
tags?: string[]; // 标签
url: string; // 下载地址
checksum?: string; // SHA256 校验和
ccrVersion?: string; // 兼容版本
}
// 在线预设仓库索引
export interface PresetRegistry {
version: string; // 索引格式版本
lastUpdated: string; // 最后更新时间
presets: PresetIndexEntry[];
}
// 配置验证结果
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
// 合并策略枚举
export enum MergeStrategy {
ASK = 'ask', // 交互式询问
OVERWRITE = 'overwrite', // 覆盖现有
MERGE = 'merge', // 智能合并
SKIP = 'skip', // 跳过冲突项
}
// 脱敏结果
export interface SanitizeResult {
sanitizedConfig: any;
requiredInputs: RequiredInput[];
sanitizedCount: number;
}
// Provider 冲突处理动作
export type ProviderConflictAction = 'keep' | 'overwrite' | 'merge' | 'skip';

View File

@@ -32,6 +32,7 @@
"react-dom": "^19.1.0",
"react-i18next": "^15.6.1",
"react-router-dom": "^7.7.0",
"remixicon": "^4.7.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
},

View File

@@ -10,13 +10,14 @@ import { LogViewer } from "@/components/LogViewer";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/components/ConfigProvider";
import { api } from "@/lib/api";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText } from "lucide-react";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText, FileCog } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Toast } from "@/components/ui/toast";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
@@ -270,19 +271,51 @@ function App() {
}
return (
<div className="h-screen bg-gray-50 font-sans">
<TooltipProvider>
<div className="h-screen bg-gray-50 font-sans">
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<h1 className="text-xl font-semibold text-gray-800">{t('app.title')}</h1>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => setIsSettingsOpen(true)} className="transition-all-ease hover:scale-110">
<Settings className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setIsJsonEditorOpen(true)} className="transition-all-ease hover:scale-110">
<FileJson className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setIsLogViewerOpen(true)} className="transition-all-ease hover:scale-110">
<FileText className="h-5 w-5" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => setIsSettingsOpen(true)} className="transition-all-ease hover:scale-110">
<Settings className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('app.settings')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => setIsJsonEditorOpen(true)} className="transition-all-ease hover:scale-110">
<FileJson className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('app.json_editor')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => setIsLogViewerOpen(true)} className="transition-all-ease hover:scale-110">
<FileText className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('app.log_viewer')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => navigate('/presets')} className="transition-all-ease hover:scale-110">
<FileCog className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('app.presets')}</p>
</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110">
@@ -310,25 +343,32 @@ function App() {
</Popover>
{/* 更新版本按钮 - 仅当更新功能可用时显示 */}
{isUpdateFeatureAvailable && (
<Button
variant="ghost"
size="icon"
onClick={() => checkForUpdates(true)}
disabled={isCheckingUpdate}
className="transition-all-ease hover:scale-110 relative"
>
<div className="relative">
<CircleArrowUp className="h-5 w-5" />
{isNewVersionAvailable && !isCheckingUpdate && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-white"></div>
)}
</div>
{isCheckingUpdate && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
</div>
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => checkForUpdates(true)}
disabled={isCheckingUpdate}
className="transition-all-ease hover:scale-110 relative"
>
<div className="relative">
<CircleArrowUp className="h-5 w-5" />
{isNewVersionAvailable && !isCheckingUpdate && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-white"></div>
)}
</div>
{isCheckingUpdate && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
</div>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('app.check_updates')}</p>
</TooltipContent>
</Tooltip>
)}
<Button onClick={saveConfig} variant="outline" className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">
<Save className="mr-2 h-4 w-4" />
@@ -412,6 +452,7 @@ function App() {
/>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,689 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Upload, Link, Trash2, Info, Download, CheckCircle2, AlertCircle, Loader2, ArrowLeft, Store, Search, Package } from "lucide-react";
import { Toast } from "@/components/ui/toast";
interface PresetMetadata {
id: string;
name: string;
version: string;
description?: string;
author?: string;
homepage?: string;
repository?: string;
license?: string;
keywords?: string[];
ccrVersion?: string;
source?: string;
sourceType?: 'local' | 'gist' | 'registry';
checksum?: string;
installed: boolean;
}
interface PresetDetail extends PresetMetadata {
config?: any;
requiredInputs?: Array<{ field: string; prompt?: string; placeholder?: string }>;
}
interface MarketPreset {
id: string;
name: string;
version: string;
description?: string;
author?: string;
homepage?: string;
repository?: string;
license?: string;
keywords?: string[];
downloadUrl: string;
downloads?: number;
rating?: number;
}
export function Presets() {
const { t } = useTranslation();
const navigate = useNavigate();
const [presets, setPresets] = useState<PresetMetadata[]>([]);
const [loading, setLoading] = useState(true);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [marketDialogOpen, setMarketDialogOpen] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<PresetDetail | null>(null);
const [presetToDelete, setPresetToDelete] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
const [installMethod, setInstallMethod] = useState<'file' | 'url'>('file');
const [installUrl, setInstallUrl] = useState('');
const [installFile, setInstallFile] = useState<File | null>(null);
const [installName, setInstallName] = useState('');
const [isInstalling, setIsInstalling] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [isApplying, setIsApplying] = useState(false);
const [marketSearch, setMarketSearch] = useState('');
const [marketPresets, setMarketPresets] = useState<MarketPreset[]>([]);
const [marketLoading, setMarketLoading] = useState(false);
const [installingFromMarket, setInstallingFromMarket] = useState<string | null>(null);
// 返回上一页
const handleGoBack = () => {
navigate('/dashboard');
};
// 加载市场预设
const loadMarketPresets = async () => {
setMarketLoading(true);
try {
// TODO: 替换为实际的市场 API
// const response = await api.getMarketPresets();
// setMarketPresets(response.presets || []);
// 模拟数据
const mockMarketPresets: MarketPreset[] = [
{
id: 'openai-compatible',
name: 'OpenAI Compatible',
version: '1.0.0',
description: 'Full-featured OpenAI API compatible preset with support for GPT-4, GPT-3.5, and more.',
author: 'CCR Community',
homepage: 'https://github.com/example/openai-preset',
repository: 'https://github.com/example/openai-preset',
license: 'MIT',
keywords: ['openai', 'gpt', 'chat'],
downloadUrl: 'https://example.com/openai.ccrsets',
downloads: 1234,
rating: 4.8
},
{
id: 'anthropic-optimized',
name: 'Anthropic Optimized',
version: '1.2.0',
description: 'Optimized configuration for Claude and other Anthropic models with enhanced token management.',
author: 'CCR Team',
homepage: 'https://github.com/example/anthropic-preset',
repository: 'https://github.com/example/anthropic-preset',
license: 'Apache-2.0',
keywords: ['anthropic', 'claude', 'ai'],
downloadUrl: 'https://example.com/anthropic.ccrsets',
downloads: 892,
rating: 4.9
},
{
id: 'multi-provider',
name: 'Multi-Provider Router',
version: '2.0.0',
description: 'Intelligent routing across multiple providers based on cost, speed, and capability.',
author: 'CCR Community',
homepage: 'https://github.com/example/multi-provider-preset',
repository: 'https://github.com/example/multi-provider-preset',
license: 'MIT',
keywords: ['router', 'multi-provider', 'optimization'],
downloadUrl: 'https://example.com/multi-provider.ccrsets',
downloads: 567,
rating: 4.6
},
{
id: 'development-tools',
name: 'Development Tools',
version: '1.1.0',
description: 'Optimized for coding and development tasks with special focus on code generation and debugging.',
author: 'DevTeam',
homepage: 'https://github.com/example/dev-tools-preset',
repository: 'https://github.com/example/dev-tools-preset',
license: 'MIT',
keywords: ['development', 'coding', 'programming'],
downloadUrl: 'https://example.com/dev-tools.ccrsets',
downloads: 445,
rating: 4.7
}
];
setMarketPresets(mockMarketPresets);
} catch (error) {
console.error('Failed to load market presets:', error);
setToast({ message: t('presets.load_market_failed'), type: 'error' });
} finally {
setMarketLoading(false);
}
};
// 从市场安装预设
const handleInstallFromMarket = async (preset: MarketPreset) => {
try {
setInstallingFromMarket(preset.id);
await api.installPresetFromUrl(preset.downloadUrl);
setToast({ message: t('presets.preset_installed'), type: 'success' });
setMarketDialogOpen(false);
await loadPresets();
} catch (error: any) {
console.error('Failed to install preset:', error);
setToast({ message: t('presets.preset_install_failed', { error: error.message }), type: 'error' });
} finally {
setInstallingFromMarket(null);
}
};
// 打开市场对话框时加载预设
useEffect(() => {
if (marketDialogOpen && marketPresets.length === 0) {
loadMarketPresets();
}
}, [marketDialogOpen]);
// 过滤市场预设
const filteredMarketPresets = marketPresets.filter(preset =>
preset.name.toLowerCase().includes(marketSearch.toLowerCase()) ||
preset.description?.toLowerCase().includes(marketSearch.toLowerCase()) ||
preset.author?.toLowerCase().includes(marketSearch.toLowerCase()) ||
preset.keywords?.some(keyword => keyword.toLowerCase().includes(marketSearch.toLowerCase()))
);
// 加载预设列表
const loadPresets = async () => {
try {
setLoading(true);
const response = await api.getPresets();
setPresets(response.presets || []);
} catch (error) {
console.error('Failed to load presets:', error);
setToast({ message: t('presets.load_presets_failed'), type: 'error' });
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPresets();
}, []);
// 查看预设详情
const handleViewDetail = async (preset: PresetMetadata) => {
try {
const detail = await api.getPreset(preset.id);
setSelectedPreset({ ...preset, ...detail });
setDetailDialogOpen(true);
// 初始化 secrets
if (detail.requiredInputs) {
const initialSecrets: Record<string, string> = {};
for (const input of detail.requiredInputs) {
initialSecrets[input.field] = '';
}
setSecrets(initialSecrets);
}
} catch (error) {
console.error('Failed to load preset details:', error);
setToast({ message: t('presets.load_preset_details_failed'), type: 'error' });
}
};
// 安装预设
const handleInstall = async () => {
try {
setIsInstalling(true);
if (installMethod === 'url' && installUrl) {
await api.installPresetFromUrl(installUrl, installName || undefined);
} else if (installMethod === 'file' && installFile) {
await api.uploadPresetFile(installFile, installName || undefined);
} else {
setToast({ message: t('presets.please_provide_file_or_url'), type: 'warning' });
return;
}
setToast({ message: t('presets.preset_installed'), type: 'success' });
setInstallDialogOpen(false);
setInstallUrl('');
setInstallFile(null);
setInstallName('');
await loadPresets();
} catch (error: any) {
console.error('Failed to install preset:', error);
setToast({ message: t('presets.preset_install_failed', { error: error.message }), type: 'error' });
} finally {
setIsInstalling(false);
}
};
// 应用预设(配置敏感信息)
const handleApplyPreset = async () => {
try {
setIsApplying(true);
// 验证所有必填项都已填写
if (selectedPreset?.requiredInputs) {
for (const input of selectedPreset.requiredInputs) {
if (!secrets[input.field] || secrets[input.field].trim() === '') {
setToast({ message: t('presets.please_fill_field', { field: input.field }), type: 'warning' });
return;
}
}
}
await api.applyPreset(selectedPreset!.name, secrets);
setToast({ message: t('presets.preset_applied'), type: 'success' });
setDetailDialogOpen(false);
setSecrets({});
} catch (error: any) {
console.error('Failed to apply preset:', error);
setToast({ message: t('presets.preset_apply_failed', { error: error.message }), type: 'error' });
} finally {
setIsApplying(false);
}
};
// 删除预设
const handleDelete = async () => {
if (!presetToDelete) return;
try {
await api.deletePreset(presetToDelete);
setToast({ message: t('presets.preset_deleted'), type: 'success' });
setDeleteDialogOpen(false);
setPresetToDelete(null);
await loadPresets();
} catch (error: any) {
console.error('Failed to delete preset:', error);
setToast({ message: t('presets.preset_delete_failed', { error: error.message }), type: 'error' });
}
};
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
<Button variant="ghost" size="icon" onClick={handleGoBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
<CardTitle className="text-lg">{t('presets.title')} <span className="text-sm font-normal text-gray-500">({presets.length})</span></CardTitle>
<Button variant="ghost" size="icon" onClick={() => setMarketDialogOpen(true)}>
<Store className="h-5 w-5" />
</Button>
</CardHeader>
<CardContent className="flex-grow overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
</div>
) : presets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Download className="h-12 w-12 mb-4 opacity-50" />
<p>{t('presets.no_presets')}</p>
<p className="text-sm">{t('presets.no_presets_hint')}</p>
</div>
) : (
<div className="space-y-3">
{presets.map((preset) => (
<div
key={preset.name}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium">{preset.name}</h3>
<span className="text-xs text-gray-500">v{preset.version}</span>
</div>
{preset.description && (
<p className="text-sm text-gray-600 mt-1">{preset.description}</p>
)}
{preset.author && (
<p className="text-xs text-gray-500 mt-1">by {preset.author}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewDetail(preset)}
>
<Info className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
setPresetToDelete(preset.id);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
{/* Install Dialog */}
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('presets.install_dialog_title')}</DialogTitle>
<DialogDescription>
{t('presets.install_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Button
variant={installMethod === 'file' ? 'default' : 'outline'}
onClick={() => setInstallMethod('file')}
className="flex-1"
>
<Upload className="mr-2 h-4 w-4" />
{t('presets.upload_file')}
</Button>
<Button
variant={installMethod === 'url' ? 'default' : 'outline'}
onClick={() => setInstallMethod('url')}
className="flex-1"
>
<Link className="mr-2 h-4 w-4" />
{t('presets.from_url')}
</Button>
</div>
{installMethod === 'file' ? (
<div className="space-y-2">
<Label htmlFor="preset-file">{t('presets.preset_file')}</Label>
<Input
id="preset-file"
type="file"
accept=".ccrsets"
onChange={(e) => setInstallFile(e.target.files?.[0] || null)}
/>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="preset-url">{t('presets.preset_url')}</Label>
<Input
id="preset-url"
type="url"
placeholder={t('presets.preset_url_placeholder')}
value={installUrl}
onChange={(e) => setInstallUrl(e.target.value)}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="preset-name">{t('presets.preset_name')}</Label>
<Input
id="preset-name"
placeholder={t('presets.preset_name_placeholder')}
value={installName}
onChange={(e) => setInstallName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>
{t('presets.close')}
</Button>
<Button onClick={handleInstall} disabled={isInstalling}>
{isInstalling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('presets.installing')}
</>
) : (
t('presets.install')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Detail Dialog */}
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedPreset?.name}
{selectedPreset?.version && (
<span className="text-sm font-normal text-gray-500">v{selectedPreset.version}</span>
)}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{selectedPreset?.description && (
<p className="text-gray-700 mb-4">{selectedPreset.description}</p>
)}
{selectedPreset?.author && (
<p className="text-sm text-gray-600 mb-1">
<strong>Author:</strong> {selectedPreset.author}
</p>
)}
{selectedPreset?.homepage && (
<p className="text-sm text-gray-600 mb-1">
<strong>Homepage:</strong> <a href={selectedPreset.homepage} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{selectedPreset.homepage}</a>
</p>
)}
{selectedPreset?.repository && (
<p className="text-sm text-gray-600 mb-1">
<strong>Repository:</strong> <a href={selectedPreset.repository} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{selectedPreset.repository}</a>
</p>
)}
{selectedPreset?.keywords && selectedPreset.keywords.length > 0 && (
<div className="mt-4">
<strong>Keywords:</strong>
<div className="flex flex-wrap gap-2 mt-2">
{selectedPreset.keywords.map((keyword) => (
<span key={keyword} className="px-2 py-1 bg-gray-100 rounded text-sm">
{keyword}
</span>
))}
</div>
</div>
)}
{selectedPreset?.requiredInputs && selectedPreset.requiredInputs.length > 0 && (
<div className="mt-6 space-y-4">
<h4 className="font-medium text-sm">{t('presets.required_information')}</h4>
{selectedPreset.requiredInputs.map((input) => (
<div key={input.field} className="space-y-2">
<Label htmlFor={`secret-${input.field}`}>
{input.prompt || input.field}
</Label>
<Input
id={`secret-${input.field}`}
type="password"
placeholder={input.placeholder || t('presets.please_fill_field', { field: input.field })}
value={secrets[input.field] || ''}
onChange={(e) => setSecrets({ ...secrets, [input.field]: e.target.value })}
/>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDetailDialogOpen(false)}>
{t('presets.close')}
</Button>
{selectedPreset?.requiredInputs && selectedPreset.requiredInputs.length > 0 && (
<Button onClick={handleApplyPreset} disabled={isApplying}>
{isApplying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('presets.applying')}
</>
) : (
<>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t('presets.apply')}
</>
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Market Presets Dialog */}
<Dialog open={marketDialogOpen} onOpenChange={setMarketDialogOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Store className="h-5 w-5" />
{t('presets.market_title')}
</DialogTitle>
<DialogDescription>
{t('presets.market_description')}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 py-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder={t('presets.search_placeholder')}
value={marketSearch}
onChange={(e) => setMarketSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{marketLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
</div>
) : filteredMarketPresets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<Package className="h-12 w-12 mb-4 opacity-50" />
<p>{t('presets.no_presets_found')}</p>
<p className="text-sm">{t('presets.no_presets_found_hint')}</p>
</div>
) : (
<div className="space-y-3">
{filteredMarketPresets.map((preset) => (
<div
key={preset.id}
className="p-4 border rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-semibold text-lg">{preset.name}</h3>
<span className="text-xs text-gray-500">v{preset.version}</span>
{preset.rating && (
<div className="flex items-center gap-1 text-xs text-yellow-600">
<span></span>
<span>{preset.rating}</span>
</div>
)}
</div>
{preset.description && (
<p className="text-sm text-gray-600 mb-2">{preset.description}</p>
)}
<div className="flex items-center gap-4 text-sm text-gray-500 mb-2">
{preset.author && (
<div className="flex items-center gap-1.5">
<span className="font-medium">{t('presets.by', { author: preset.author })}</span>
{preset.repository && (
<a
href={preset.repository}
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
title={t('presets.github_repository')}
>
<i className="ri-github-fill text-base"></i>
</a>
)}
</div>
)}
{preset.downloads && (
<span>{t('presets.downloads', { count: preset.downloads })}</span>
)}
{preset.license && (
<span>{preset.license}</span>
)}
</div>
{preset.keywords && preset.keywords.length > 0 && (
<div className="flex flex-wrap gap-1">
{preset.keywords.map((keyword) => (
<span
key={keyword}
className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs"
>
{keyword}
</span>
))}
</div>
)}
</div>
<Button
onClick={() => handleInstallFromMarket(preset)}
disabled={installingFromMarket === preset.id}
className="shrink-0"
>
{installingFromMarket === preset.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('presets.installing')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
{t('presets.install')}
</>
)}
</Button>
</div>
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('presets.delete_dialog_title')}</DialogTitle>
<DialogDescription>
{t('presets.delete_dialog_description', { name: presetToDelete })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
{t('presets.close')}
</Button>
<Button variant="destructive" onClick={handleDelete}>
{t('presets.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</Card>
);
}

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-gray-900 px-3 py-1.5 text-xs text-white animate-in fade-in-0 zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -235,6 +235,73 @@ class ApiClient {
async clearLogs(filePath: string): Promise<void> {
return this.delete<void>(`/logs?file=${encodeURIComponent(filePath)}`);
}
// ========== Preset API methods ==========
// Get presets list
async getPresets(): Promise<{ presets: Array<any> }> {
return this.get<{ presets: Array<any> }>('/presets');
}
// Get preset details
async getPreset(name: string): Promise<any> {
return this.get<any>(`/presets/${encodeURIComponent(name)}`);
}
// Install preset from URL
async installPresetFromUrl(url: string, name?: string): Promise<any> {
return this.post<any>('/presets/install', { url, name });
}
// Upload preset file
async uploadPresetFile(file: File, name?: string): Promise<any> {
const formData = new FormData();
formData.append('file', file);
if (name) {
formData.append('name', name);
}
const url = `${this.baseUrl}/presets/upload`;
const headers: Record<string, string> = {
'Accept': 'application/json',
};
// Use temp API key if available, otherwise use regular API key
if (this.tempApiKey) {
headers['X-Temp-API-Key'] = this.tempApiKey;
} else if (this.apiKey) {
headers['X-API-Key'] = this.apiKey;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
});
if (response.status === 401) {
localStorage.removeItem('apiKey');
window.dispatchEvent(new CustomEvent('unauthorized'));
return new Promise(() => {}) as any;
}
if (!response.ok) {
throw new Error(`Failed to upload preset: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Apply preset (configure sensitive fields)
async applyPreset(name: string, secrets: Record<string, string>): Promise<any> {
return this.post<any>(`/presets/${encodeURIComponent(name)}/apply`, { secrets });
}
// Delete preset
async deletePreset(name: string): Promise<any> {
return this.delete<any>(`/presets/${encodeURIComponent(name)}`);
}
}
// Create a default instance of the API client

View File

@@ -25,7 +25,12 @@
"no_updates_available": "No updates available",
"update_check_failed": "Failed to check for updates",
"update_successful": "Update successful",
"update_failed": "Update failed"
"update_failed": "Update failed",
"json_editor": "JSON Editor",
"log_viewer": "Log Viewer",
"presets": "Presets",
"language": "Language",
"check_updates": "Check for Updates"
},
"login": {
"title": "Sign in to your account",
@@ -233,5 +238,50 @@
"worker_init_failed": "Failed to initialize worker",
"grouping_not_supported": "Log grouping not supported by server",
"back": "Back"
},
"presets": {
"title": "Presets",
"market_title": "Preset Market",
"market_description": "Browse and install presets from the community marketplace",
"no_presets": "No presets installed",
"no_presets_hint": "Install a preset to get started",
"search_placeholder": "Search presets by name, description, author, or keywords...",
"no_presets_found": "No presets found",
"no_presets_found_hint": "Try adjusting your search terms",
"loading": "Loading...",
"by": "by {{author}}",
"downloads": "{{count}} downloads",
"github_repository": "GitHub Repository",
"view_details": "View Details",
"install": "Install",
"installing": "Installing...",
"apply": "Apply Preset",
"applying": "Applying...",
"close": "Close",
"delete": "Delete",
"install_dialog_title": "Install Preset",
"install_dialog_description": "Install a preset from a file or URL",
"upload_file": "Upload File",
"from_url": "From URL",
"preset_file": "Preset File (.ccrsets)",
"preset_url": "Preset URL",
"preset_url_placeholder": "https://example.com/preset.ccrsets",
"preset_name": "Preset Name (Optional)",
"preset_name_placeholder": "Auto-generated from file",
"please_provide_file_or_url": "Please provide a file or URL",
"detail_dialog_title": "Preset Details",
"required_information": "Required Information",
"delete_dialog_title": "Delete Preset",
"delete_dialog_description": "Are you sure you want to delete preset \"{{name}}\"? This action cannot be undone.",
"preset_installed": "Preset installed successfully",
"preset_install_failed": "Failed to install preset: {{error}}",
"preset_applied": "Preset applied successfully",
"preset_apply_failed": "Failed to apply preset: {{error}}",
"preset_deleted": "Preset deleted successfully",
"preset_delete_failed": "Failed to delete preset: {{error}}",
"load_presets_failed": "Failed to load presets",
"load_preset_details_failed": "Failed to load preset details",
"please_fill_field": "Please fill in {{field}}",
"load_market_failed": "Failed to load market presets"
}
}

View File

@@ -25,7 +25,12 @@
"no_updates_available": "当前已是最新版本",
"update_check_failed": "检查更新失败",
"update_successful": "更新成功",
"update_failed": "更新失败"
"update_failed": "更新失败",
"json_editor": "JSON 编辑器",
"log_viewer": "日志查看器",
"presets": "预设",
"language": "语言",
"check_updates": "检查更新"
},
"login": {
"title": "登录到您的账户",
@@ -233,5 +238,50 @@
"worker_init_failed": "Worker初始化失败",
"grouping_not_supported": "服务器不支持日志分组",
"back": "返回"
},
"presets": {
"title": "预设",
"market_title": "预设市场",
"market_description": "浏览并从社区市场安装预设",
"no_presets": "暂无已安装的预设",
"no_presets_hint": "安装一个预设以开始使用",
"search_placeholder": "按名称、描述、作者或关键词搜索预设...",
"no_presets_found": "未找到预设",
"no_presets_found_hint": "请尝试调整搜索条件",
"loading": "加载中...",
"by": "{{author}} 创作",
"downloads": "{{count}} 次下载",
"github_repository": "GitHub 仓库",
"view_details": "查看详情",
"install": "安装",
"installing": "安装中...",
"apply": "应用预设",
"applying": "应用中...",
"close": "关闭",
"delete": "删除",
"install_dialog_title": "安装预设",
"install_dialog_description": "从文件或URL安装预设",
"upload_file": "上传文件",
"from_url": "从 URL",
"preset_file": "预设文件 (.ccrsets)",
"preset_url": "预设 URL",
"preset_url_placeholder": "https://example.com/preset.ccrsets",
"preset_name": "预设名称 (可选)",
"preset_name_placeholder": "从文件自动生成",
"please_provide_file_or_url": "请提供文件或 URL",
"detail_dialog_title": "预设详情",
"required_information": "必需信息",
"delete_dialog_title": "删除预设",
"delete_dialog_description": "您确定要删除预设 \"{{name}}\" 吗?此操作无法撤销。",
"preset_installed": "预设安装成功",
"preset_install_failed": "预设安装失败:{{error}}",
"preset_applied": "预设应用成功",
"preset_apply_failed": "预设应用失败:{{error}}",
"preset_deleted": "预设删除成功",
"preset_delete_failed": "预设删除失败:{{error}}",
"load_presets_failed": "加载预设失败",
"load_preset_details_failed": "加载预设详情失败",
"please_fill_field": "请填写 {{field}}",
"load_market_failed": "加载市场预设失败"
}
}

View File

@@ -2,6 +2,7 @@ import './i18n';
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import 'remixicon/fonts/remixicon.css'
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
import { ConfigProvider } from '@/components/ConfigProvider';

View File

@@ -2,6 +2,7 @@ import { createMemoryRouter, Navigate } from 'react-router-dom';
import App from './App';
import { Login } from '@/components/Login';
import { DebugPage } from '@/components/DebugPage';
import { Presets } from '@/components/Presets';
import ProtectedRoute from '@/components/ProtectedRoute';
import PublicRoute from '@/components/PublicRoute';
@@ -18,6 +19,10 @@ export const router = createMemoryRouter([
path: '/dashboard',
element: <ProtectedRoute><App /></ProtectedRoute>,
},
{
path: '/presets',
element: <ProtectedRoute><Presets /></ProtectedRoute>,
},
{
path: '/debug',
element: <ProtectedRoute><DebugPage /></ProtectedRoute>,

348
pnpm-lock.yaml generated
View File

@@ -75,6 +75,12 @@ importers:
'@inquirer/prompts':
specifier: ^5.0.0
version: 5.5.0
adm-zip:
specifier: ^0.5.16
version: 0.5.16
archiver:
specifier: ^7.0.1
version: 7.0.1
find-process:
specifier: ^2.0.0
version: 2.0.0
@@ -85,6 +91,9 @@ importers:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
'@types/node':
specifier: ^24.0.15
version: 24.7.0
@@ -100,12 +109,18 @@ importers:
packages/server:
dependencies:
'@fastify/multipart':
specifier: ^9.0.0
version: 9.3.0
'@fastify/static':
specifier: ^8.2.0
version: 8.2.0
'@musistudio/llms':
specifier: ^1.0.51
version: 1.0.51(ws@8.18.3)
adm-zip:
specifier: ^0.5.16
version: 0.5.16
dotenv:
specifier: ^16.4.7
version: 16.6.1
@@ -131,6 +146,9 @@ importers:
'@CCR/shared':
specifier: workspace:*
version: link:../shared
'@types/adm-zip':
specifier: ^0.5.7
version: 0.5.7
'@types/node':
specifier: ^24.0.15
version: 24.7.0
@@ -148,7 +166,23 @@ importers:
version: 5.8.3
packages/shared:
dependencies:
adm-zip:
specifier: ^0.5.16
version: 0.5.16
archiver:
specifier: ^7.0.1
version: 7.0.1
json5:
specifier: ^2.2.3
version: 2.2.3
devDependencies:
'@types/adm-zip':
specifier: ^0.5.7
version: 0.5.7
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
'@types/node':
specifier: ^24.0.15
version: 24.7.0
@@ -227,6 +261,9 @@ importers:
react-router-dom:
specifier: ^7.7.0
version: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
remixicon:
specifier: ^4.7.0
version: 4.7.0
tailwind-merge:
specifier: ^3.3.1
version: 3.4.0
@@ -1837,9 +1874,15 @@ packages:
'@fastify/ajv-compiler@4.0.2':
resolution: {integrity: sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ==}
'@fastify/busboy@3.2.0':
resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
'@fastify/cors@11.1.0':
resolution: {integrity: sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA==}
'@fastify/deepmerge@3.1.0':
resolution: {integrity: sha512-lCVONBQINyNhM6LLezB6+2afusgEYR4G8xenMsfe+AT+iZ7Ca6upM5Ha8UkZuYSnuMw3GWl/BiPXnLMi/gSxuQ==}
'@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
@@ -1852,6 +1895,9 @@ packages:
'@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
'@fastify/multipart@9.3.0':
resolution: {integrity: sha512-NpeKipTOjjL1dA7SSlRMrOWWtrE8/0yKOmeudkdQoEaz4sVDJw5MVdZIahsWhvpc3YTN7f04f9ep/Y65RKoOWA==}
'@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
@@ -2090,6 +2136,10 @@ packages:
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@pnpm/config.env-replace@1.1.0':
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
engines: {node: '>=12.22.0'}
@@ -2812,6 +2862,12 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@types/adm-zip@0.5.7':
resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==}
'@types/archiver@7.0.0':
resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2951,6 +3007,9 @@ packages:
'@types/react@18.3.27':
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==}
'@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
@@ -3116,6 +3175,10 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -3147,6 +3210,10 @@ packages:
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
engines: {node: '>= 10.0.0'}
adm-zip@0.5.16:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -3237,6 +3304,14 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
archiver-utils@5.0.2:
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
engines: {node: '>= 14'}
archiver@7.0.1:
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
engines: {node: '>= 14'}
arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
@@ -3264,6 +3339,9 @@ packages:
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
hasBin: true
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -3278,6 +3356,14 @@ packages:
avvio@9.1.0:
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
b4a@1.7.3:
resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
babel-loader@9.2.1:
resolution: {integrity: sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==}
engines: {node: '>= 14.15.0'}
@@ -3309,6 +3395,14 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -3362,12 +3456,19 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
@@ -3564,6 +3665,10 @@ packages:
common-path-prefix@3.0.0:
resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==}
compress-commons@6.0.2:
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
engines: {node: '>= 14'}
compressible@2.0.18:
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
engines: {node: '>= 0.6'}
@@ -3643,6 +3748,15 @@ packages:
typescript:
optional: true
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
crc32-stream@6.0.0:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
@@ -4157,9 +4271,16 @@ packages:
resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==}
engines: {node: '>= 0.8'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -4197,6 +4318,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -4413,6 +4537,10 @@ packages:
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
glob@11.0.3:
resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==}
engines: {node: 20 || >=22}
@@ -4655,6 +4783,9 @@ packages:
peerDependencies:
postcss: ^8.1.0
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -4857,6 +4988,9 @@ packages:
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
engines: {node: '>=0.10.0'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jackspeak@4.1.1:
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
engines: {node: 20 || >=22}
@@ -4960,6 +5094,10 @@ packages:
launch-editor@2.12.0:
resolution: {integrity: sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==}
lazystream@1.0.1:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@@ -5097,6 +5235,9 @@ packages:
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.2:
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
engines: {node: 20 || >=22}
@@ -5409,6 +5550,10 @@ packages:
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@5.1.6:
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
engines: {node: '>=10'}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -5702,6 +5847,10 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-scurry@2.0.0:
resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==}
engines: {node: 20 || >=22}
@@ -6208,6 +6357,10 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@@ -6423,6 +6576,13 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdir-glob@1.1.3:
resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
@@ -6513,6 +6673,9 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
remixicon@4.7.0:
resolution: {integrity: sha512-g2pHOofQWARWpxdbrQu5+K3C8ZbqguQFzE54HIMWFCpFa63pumaAltIgZmFMRQpKKBScRWQASQfWxS9asNCcHQ==}
renderkid@3.0.0:
resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==}
@@ -6840,6 +7003,9 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
streamx@2.23.0:
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -6951,6 +7117,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
terser-webpack-plugin@5.3.16:
resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==}
engines: {node: '>= 10.13.0'}
@@ -6972,6 +7141,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
text-decoder@1.2.3:
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -7499,6 +7671,10 @@ packages:
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
engines: {node: '>=18'}
zip-stream@6.0.1:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zod@4.2.1:
resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==}
@@ -9702,11 +9878,15 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.17.1)
fast-uri: 3.1.0
'@fastify/busboy@3.2.0': {}
'@fastify/cors@11.1.0':
dependencies:
fastify-plugin: 5.1.0
toad-cache: 3.7.0
'@fastify/deepmerge@3.1.0': {}
'@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.0.3':
@@ -9719,6 +9899,14 @@ snapshots:
dependencies:
dequal: 2.0.3
'@fastify/multipart@9.3.0':
dependencies:
'@fastify/busboy': 3.2.0
'@fastify/deepmerge': 3.1.0
'@fastify/error': 4.2.0
fastify-plugin: 5.1.0
secure-json-parse: 4.1.0
'@fastify/proxy-addr@5.1.0':
dependencies:
'@fastify/forwarded': 3.0.1
@@ -10065,6 +10253,9 @@ snapshots:
'@opentelemetry/api@1.9.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
'@pnpm/config.env-replace@1.1.0': {}
'@pnpm/network.ca-file@1.0.2':
@@ -10703,6 +10894,14 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
'@types/adm-zip@0.5.7':
dependencies:
'@types/node': 24.7.0
'@types/archiver@7.0.0':
dependencies:
'@types/readdir-glob': 1.1.5
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.5
@@ -10872,6 +11071,10 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 24.7.0
'@types/retry@0.12.2': {}
'@types/sax@1.2.7':
@@ -11107,6 +11310,10 @@ snapshots:
'@xtuc/long@4.2.2': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
abstract-logging@2.0.1: {}
accepts@1.3.8:
@@ -11130,6 +11337,8 @@ snapshots:
address@1.2.2: {}
adm-zip@0.5.16: {}
agent-base@7.1.4: {}
aggregate-error@3.1.0:
@@ -11225,6 +11434,29 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
archiver-utils@5.0.2:
dependencies:
glob: 10.5.0
graceful-fs: 4.2.11
is-stream: 2.0.1
lazystream: 1.0.1
lodash: 4.17.21
normalize-path: 3.0.0
readable-stream: 4.7.0
archiver@7.0.1:
dependencies:
archiver-utils: 5.0.2
async: 3.2.6
buffer-crc32: 1.0.0
readable-stream: 4.7.0
readdir-glob: 1.1.3
tar-stream: 3.1.7
zip-stream: 6.0.1
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
arg@4.1.3: {}
arg@5.0.2: {}
@@ -11245,6 +11477,8 @@ snapshots:
astring@1.9.0: {}
async@3.2.6: {}
atomic-sleep@1.0.0: {}
autoprefixer@10.4.23(postcss@8.5.6):
@@ -11261,6 +11495,8 @@ snapshots:
'@fastify/error': 4.2.0
fastq: 1.19.1
b4a@1.7.3: {}
babel-loader@9.2.1(@babel/core@7.28.5)(webpack@5.104.1(esbuild@0.25.10)):
dependencies:
'@babel/core': 7.28.5
@@ -11300,6 +11536,8 @@ snapshots:
balanced-match@1.0.2: {}
bare-events@2.8.2: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.9.11: {}
@@ -11379,10 +11617,17 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
@@ -11570,6 +11815,14 @@ snapshots:
common-path-prefix@3.0.0: {}
compress-commons@6.0.2:
dependencies:
crc-32: 1.2.2
crc32-stream: 6.0.0
is-stream: 2.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
compressible@2.0.18:
dependencies:
mime-db: 1.54.0
@@ -11650,6 +11903,13 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
crc-32@1.2.2: {}
crc32-stream@6.0.0:
dependencies:
crc-32: 1.2.2
readable-stream: 4.7.0
create-require@1.1.1: {}
cross-spawn@6.0.6:
@@ -12232,8 +12492,16 @@ snapshots:
'@types/node': 24.7.0
require-like: 0.1.2
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.8.2
transitivePeerDependencies:
- bare-abort-controller
events@3.3.0: {}
eventsource-parser@3.0.6: {}
@@ -12312,6 +12580,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -12564,6 +12834,15 @@ snapshots:
glob-to-regexp@0.4.1: {}
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
glob@11.0.3:
dependencies:
foreground-child: 3.3.1
@@ -12941,6 +13220,8 @@ snapshots:
dependencies:
postcss: 8.5.6
ieee754@1.2.1: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -13074,6 +13355,12 @@ snapshots:
isobject@3.0.1: {}
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jackspeak@4.1.1:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -13183,6 +13470,10 @@ snapshots:
picocolors: 1.1.1
shell-quote: 1.8.3
lazystream@1.0.1:
dependencies:
readable-stream: 2.3.8
leven@3.1.0: {}
levn@0.4.1:
@@ -13289,6 +13580,8 @@ snapshots:
lowercase-keys@3.0.0: {}
lru-cache@10.4.3: {}
lru-cache@11.2.2: {}
lru-cache@5.1.1:
@@ -13874,6 +14167,10 @@ snapshots:
dependencies:
brace-expansion: 1.1.12
minimatch@5.1.6:
dependencies:
brace-expansion: 2.0.2
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
@@ -14141,6 +14438,11 @@ snapshots:
path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.2
path-scurry@2.0.0:
dependencies:
lru-cache: 11.2.2
@@ -14676,6 +14978,8 @@ snapshots:
process-warning@5.0.0: {}
process@0.11.10: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@@ -14894,6 +15198,18 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readdir-glob@1.1.3:
dependencies:
minimatch: 5.1.6
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
@@ -15049,6 +15365,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
remixicon@4.7.0: {}
renderkid@3.0.0:
dependencies:
css-select: 4.3.0
@@ -15417,6 +15735,15 @@ snapshots:
std-env@3.10.0: {}
streamx@2.23.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.3
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -15556,6 +15883,15 @@ snapshots:
tapable@2.3.0: {}
tar-stream@3.1.7:
dependencies:
b4a: 1.7.3
fast-fifo: 1.3.2
streamx: 2.23.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
terser-webpack-plugin@5.3.16(esbuild@0.25.10)(webpack@5.104.1(esbuild@0.25.10)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -15574,6 +15910,12 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
text-decoder@1.2.3:
dependencies:
b4a: 1.7.3
transitivePeerDependencies:
- react-native-b4a
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -16090,6 +16432,12 @@ snapshots:
yoctocolors-cjs@2.1.3: {}
zip-stream@6.0.1:
dependencies:
archiver-utils: 5.0.2
compress-commons: 6.0.2
readable-stream: 4.7.0
zod@4.2.1: {}
zwitch@2.0.4: {}