From 60a1f948787e744b645dd09581e7d00178383171 Mon Sep 17 00:00:00 2001 From: musistudio Date: Sun, 28 Dec 2025 22:41:56 +0800 Subject: [PATCH] move llms to core package --- CLAUDE.md | 90 +++++++- README.md | 41 +++- README_zh.md | 66 +++++- package.json | 2 + packages/cli/src/cli.ts | 6 +- packages/cli/src/utils/preset/install.ts | 19 +- packages/cli/src/utils/prompt/schema-input.ts | 11 +- packages/server/package.json | 2 +- packages/server/src/index.ts | 12 +- packages/server/src/server.ts | 21 +- packages/shared/src/index.ts | 1 + packages/shared/src/preset/install.ts | 115 +++++++++- packages/shared/src/preset/merge.ts | 75 +----- packages/shared/src/preset/sensitiveFields.ts | 6 +- packages/shared/src/preset/types.ts | 16 +- packages/ui/package.json | 2 + packages/ui/src/components/Presets.tsx | 129 +++++++++-- .../components/preset/DynamicConfigForm.tsx | 12 +- packages/ui/src/lib/api.ts | 59 ++--- packages/ui/src/locales/en.json | 5 +- packages/ui/src/locales/zh.json | 5 +- pnpm-lock.yaml | 216 ++++++++++++++---- scripts/build.js | 15 +- scripts/release.sh | 36 ++- 24 files changed, 742 insertions(+), 220 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a1f4e10..205cab7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Claude Code Router is a tool that routes Claude Code requests to different LLM p - **cli** (`@musistudio/claude-code-router-cli`): Command-line tool providing the `ccr` command - **server** (`@musistudio/claude-code-router-server`): Core server handling API routing and transformations -- **shared** (`@musistudio/claude-code-router-shared`): Shared constants and utilities +- **shared** (`@musistudio/claude-code-router-shared`): Shared constants, utilities, and preset management - **ui** (`@musistudio/claude-code-router-ui`): Web management interface (React + Vite) ## Build Commands @@ -127,11 +127,22 @@ ccr restart # Restart server ccr status # Show status ccr code # Execute claude command ccr model # Interactive model selection and configuration +ccr preset # Manage presets (export, install, list, info, delete) ccr activate # Output shell environment variables (for integration) ccr ui # Open Web UI ccr statusline # Integrated statusline (reads JSON from stdin) ``` +### Preset Commands + +```bash +ccr preset export # Export current configuration as a preset +ccr preset install # Install a preset from file, URL, or name +ccr preset list # List all installed presets +ccr preset info # Show preset information +ccr preset delete # Delete a preset +``` + ## Subagent Routing Use special tags in subagent prompts to specify models: @@ -140,6 +151,83 @@ Use special tags in subagent prompts to specify models: Please help me analyze this code... ``` +## Preset System + +The preset system allows users to save, share, and reuse configurations easily. + +### Preset Structure + +Presets are stored in `~/.claude-code-router/presets//manifest.json` + +Each preset contains: +- **Metadata**: name, version, description, author, keywords, etc. +- **Configuration**: Providers, Router, transformers, and other settings +- **Dynamic Schema** (optional): Input fields for collecting required information during installation +- **Required Inputs** (optional): Fields that need to be filled during installation (e.g., API keys) + +### Core Functions + +Located in `packages/shared/src/preset/`: + +- **export.ts**: Export current configuration as a preset (.ccrsets file) + - `exportPreset(presetName, config, options)`: Creates ZIP archive with manifest.json + - Automatically sanitizes sensitive data (api_key fields become `{{field}}` placeholders) + +- **install.ts**: Install and manage presets + - `installPreset(preset, config, options)`: Install preset to config + - `loadPreset(source)`: Load preset from file, URL, or directory + - `listPresets()`: List all installed presets + - `isPresetInstalled(presetName)`: Check if preset is installed + - `validatePreset(preset)`: Validate preset structure + +- **merge.ts**: Merge preset configuration with existing config + - Handles conflicts using different strategies (ask, overwrite, merge, skip) + +- **sensitiveFields.ts**: Identify and sanitize sensitive fields + - Detects api_key, password, secret fields automatically + - Creates `requiredInputs` array for installation prompts + +### Preset File Format + +**manifest.json** (in ZIP archive or extracted directory): +```json +{ + "name": "my-preset", + "version": "1.0.0", + "description": "My configuration", + "author": "Author Name", + "keywords": ["openai", "production"], + "Providers": [...], + "Router": {...}, + "schema": [ + { + "id": "apiKey", + "type": "password", + "label": "OpenAI API Key", + "prompt": "Enter your OpenAI API key" + } + ], + "requiredInputs": [ + { + "field": "Providers[0].api_key", + "placeholder": "Enter your API key" + } + ] +} +``` + +### CLI Integration + +The CLI layer (`packages/cli/src/utils/preset/`) handles: +- User interaction and prompts +- File operations +- Display formatting + +Key files: +- `commands.ts`: Command handlers for `ccr preset` subcommands +- `export.ts`: CLI wrapper for export functionality +- `install.ts`: CLI wrapper for install functionality + ## Dependencies ``` diff --git a/README.md b/README.md index 1f37b71..702955f 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,46 @@ This command provides an interactive interface to: The CLI tool validates all inputs and provides helpful prompts to guide you through the configuration process, making it easy to manage complex setups without editing JSON files manually. -### 6. Activate Command (Environment Variables Setup) +### 6. Presets Management + +Presets allow you to save, share, and reuse configurations easily. You can export your current configuration as a preset and install presets from files or URLs. + +```shell +# Export current configuration as a preset +ccr preset export my-preset + +# Export with metadata +ccr preset export my-preset --description "My OpenAI config" --author "Your Name" --tags "openai,production" + +# Install a preset from file, URL, or registry +ccr preset install my-preset.ccrsets +ccr preset install https://example.com/preset.ccrsets + +# List all installed presets +ccr preset list + +# Show preset information +ccr preset info my-preset + +# Delete a preset +ccr preset delete my-preset +``` + +**Preset Features:** +- **Export**: Save your current configuration as a `.ccrsets` file (ZIP archive with manifest.json) +- **Install**: Install presets from local files, URLs, or the preset registry +- **Sensitive Data Handling**: API keys and other sensitive data are automatically sanitized during export (marked as `{{field}}` placeholders) +- **Dynamic Configuration**: Presets can include input schemas for collecting required information during installation +- **Version Control**: Each preset includes version metadata for tracking updates + +**Preset File Structure:** +``` +~/.claude-code-router/presets/ +├── my-preset/ +│ └── manifest.json # Contains configuration and metadata +``` + +### 7. Activate Command (Environment Variables Setup) The `activate` command allows you to set up environment variables globally in your shell, enabling you to use the `claude` command directly or integrate Claude Code Router with applications built using the Agent SDK. diff --git a/README_zh.md b/README_zh.md index 7d52a4c..8ecb847 100644 --- a/README_zh.md +++ b/README_zh.md @@ -203,7 +203,71 @@ ccr ui ![UI](/blog/images/ui.png) -### 5. Activate 命令(环境变量设置) +### 5. CLI 模型管理 + +对于偏好终端工作流的用户,可以使用交互式 CLI 模型选择器: + +```shell +ccr model +``` + +该命令提供交互式界面来: + +- 查看当前配置 +- 查看所有配置的模型(default、background、think、longContext、webSearch、image) +- 切换模型:快速更改每个路由器类型使用的模型 +- 添加新模型:向现有提供商添加模型 +- 创建新提供商:设置完整的提供商配置,包括: + - 提供商名称和 API 端点 + - API 密钥 + - 可用模型 + - Transformer 配置,支持: + - 多个转换器(openrouter、deepseek、gemini 等) + - Transformer 选项(例如,带自定义限制的 maxtoken) + - 特定于提供商的路由(例如,OpenRouter 提供商偏好) + +CLI 工具验证所有输入并提供有用的提示来引导您完成配置过程,使管理复杂的设置变得容易,无需手动编辑 JSON 文件。 + +### 6. 预设管理 + +预设允许您轻松保存、共享和重用配置。您可以将当前配置导出为预设,并从文件或 URL 安装预设。 + +```shell +# 将当前配置导出为预设 +ccr preset export my-preset + +# 使用元数据导出 +ccr preset export my-preset --description "我的 OpenAI 配置" --author "您的名字" --tags "openai,生产环境" + +# 从文件、URL 或注册表安装预设 +ccr preset install my-preset.ccrsets +ccr preset install https://example.com/preset.ccrsets + +# 列出所有已安装的预设 +ccr preset list + +# 显示预设信息 +ccr preset info my-preset + +# 删除预设 +ccr preset delete my-preset +``` + +**预设功能:** +- **导出**:将当前配置保存为 `.ccrsets` 文件(包含 manifest.json 的 ZIP 存档) +- **安装**:从本地文件、URL 或预设注册表安装预设 +- **敏感数据处理**:导出期间自动清理 API 密钥和其他敏感数据(标记为 `{{field}}` 占位符) +- **动态配置**:预设可以包含输入架构,用于在安装期间收集所需信息 +- **版本控制**:每个预设包含版本元数据,用于跟踪更新 + +**预设文件结构:** +``` +~/.claude-code-router/presets/ +├── my-preset/ +│ └── manifest.json # 包含配置和元数据 +``` + +### 7. Activate 命令(环境变量设置) `activate` 命令允许您在 shell 中全局设置环境变量,使您能够直接使用 `claude` 命令或将 Claude Code Router 与使用 Agent SDK 构建的应用程序集成。 diff --git a/package.json b/package.json index 819c9eb..2e3f8b8 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "private": true, "scripts": { "build": "node scripts/build.js", + "build:core": "node scripts/build-core.js", + "build:shared": "node scripts/build-shared.js", "build:cli": "pnpm --filter @CCR/cli build", "build:server": "pnpm --filter @CCR/server build", "build:ui": "pnpm --filter @CCR/ui build", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c49d1be..58993dc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,10 +12,12 @@ import { activateCommand } from "./utils/activateCommand"; import { readConfigFile } from "./utils"; import { version } from "../package.json"; import { spawn, exec } from "child_process"; -import { PID_FILE, REFERENCE_COUNT_FILE } from "@CCR/shared"; +import {PID_FILE, readPresetFile, REFERENCE_COUNT_FILE} from "@CCR/shared"; import fs, { existsSync, readFileSync } from "fs"; import { join } from "path"; import { parseStatusLineData, StatusLineInput } from "./utils/statusline"; +import {handlePresetCommand} from "./utils/preset"; + const command = process.argv[2]; @@ -95,7 +97,6 @@ async function main() { // 如果命令不是已知命令,检查是否是 preset if (command && !KNOWN_COMMANDS.includes(command)) { - const { readPresetFile } = await import("./utils"); const presetData: any = await readPresetFile(command); if (presetData) { @@ -248,7 +249,6 @@ async function main() { await runModelSelector(); break; case "preset": - const { handlePresetCommand } = await import("./utils/preset"); await handlePresetCommand(process.argv.slice(3)); break; case "activate": diff --git a/packages/cli/src/utils/preset/install.ts b/packages/cli/src/utils/preset/install.ts index 9031f4c..cae167f 100644 --- a/packages/cli/src/utils/preset/install.ts +++ b/packages/cli/src/utils/preset/install.ts @@ -105,20 +105,11 @@ export async function applyPresetCli( 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`); - console.log(`${DIM}You can use this preset with: ccr ${presetName}${RESET}\n`); - return; - } - } catch { - // manifest不存在,继续配置流程 + // 检查是否需要配置 + if (preset.schema && preset.schema.length > 0) { + console.log(`\n${BOLDCYAN}Configuration required:${RESET} ${preset.schema.length} field(s)\n`); + } else { + console.log(`\n${DIM}No configuration required for this preset${RESET}\n`); } // 收集用户输入 diff --git a/packages/cli/src/utils/prompt/schema-input.ts b/packages/cli/src/utils/prompt/schema-input.ts index 7c40cac..cfcba5b 100644 --- a/packages/cli/src/utils/prompt/schema-input.ts +++ b/packages/cli/src/utils/prompt/schema-input.ts @@ -14,8 +14,13 @@ import { getDefaultValue, sortFieldsByDependencies, getAffectedFields, -} from '@musistudio/claude-code-router-shared'; -import { input, confirm, select, password } from '@inquirer/prompts'; +} from '@CCR/shared'; +import input from '@inquirer/input'; +import confirm from '@inquirer/confirm'; +import select from '@inquirer/select'; +import password from '@inquirer/password'; +import checkbox from '@inquirer/checkbox'; +import editor from '@inquirer/editor'; // ANSI 颜色代码 export const COLORS = { @@ -183,7 +188,6 @@ async function promptField( } // @inquirer/prompts 没有多选,使用 checkbox - const { checkbox } = await import('@inquirer/prompts'); return await checkbox({ message, choices: options.map(opt => ({ @@ -195,7 +199,6 @@ async function promptField( } case InputType.EDITOR: { - const { editor } = await import('@inquirer/prompts'); return await editor({ message, default: field.defaultValue, diff --git a/packages/server/package.json b/packages/server/package.json index e4b87a0..b744a38 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -19,7 +19,7 @@ "dependencies": { "@fastify/multipart": "^9.0.0", "@fastify/static": "^8.2.0", - "@musistudio/llms": "^1.0.51", + "@musistudio/llms": "workspace:*", "adm-zip": "^0.5.16", "dotenv": "^16.4.7", "json5": "^2.2.3", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b9f4bf9..de15c1a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,4 +1,4 @@ -import { existsSync, writeFileSync, unlinkSync } from "fs"; +import { existsSync } from "fs"; import { writeFile } from "fs/promises"; import { homedir } from "os"; import { join } from "path"; @@ -6,7 +6,7 @@ import { initConfig, initDir } from "./utils"; import { createServer } from "./server"; import { router } from "./utils/router"; import { apiKeyAuth } from "./middleware/auth"; -import { CONFIG_FILE, HOME_DIR } from "@CCR/shared"; +import {CONFIG_FILE, HOME_DIR, listPresets} from "@CCR/shared"; import { createStream } from 'rotating-file-stream'; import { sessionUsageCache } from "./utils/cache"; import {SSEParserTransform} from "./utils/SSEParser.transform"; @@ -16,7 +16,6 @@ import JSON5 from "json5"; import { IAgent, ITool } from "./agents/type"; import agentsManager from "./agents"; import { EventEmitter } from "node:events"; -import {spawn} from "child_process"; const event = new EventEmitter() @@ -121,6 +120,8 @@ async function getServer(options: RunOptions = {}) { } } + const presets = await listPresets(); + const serverInstance = await createServer({ jsonPath: CONFIG_FILE, initialConfig: { @@ -137,6 +138,11 @@ async function getServer(options: RunOptions = {}) { logger: loggerConfig, }); + presets.forEach(preset => { + console.log(preset.name, preset.config); + serverInstance.registerNamespace(preset.name, preset.config); + }) + // Add async preHandler hook for authentication serverInstance.addHook("preHandler", async (req: any, reply: any) => { return new Promise((resolve, reject) => { diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index cad8e71..ba580a7 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -10,8 +10,6 @@ import { readManifestFromDir, manifestToPresetFile, extractPreset, - validatePreset, - loadPreset, saveManifest, isPresetInstalled, downloadPresetToTemp, @@ -20,16 +18,15 @@ import { type PresetFile, type ManifestFile, type PresetMetadata, - MergeStrategy } from "@CCR/shared"; +import fastifyMultipart from "@fastify/multipart"; +import AdmZip from "adm-zip"; export const createServer = async (config: any): Promise => { const server = new Server(config); const app = server.app; - // Register multipart plugin for file uploads (dynamic import) - const fastifyMultipart = await import('@fastify/multipart'); - app.register(fastifyMultipart.default, { + app.register(fastifyMultipart, { limits: { fileSize: 50 * 1024 * 1024, // 50MB }, @@ -171,8 +168,6 @@ export const createServer = async (config: any): Promise => { } }); - // ========== Preset 相关 API ========== - // 获取预设列表 app.get("/api/presets", async (req: any, reply: any) => { try { @@ -435,8 +430,13 @@ export const createServer = async (config: any): Promise => { } // 解析 GitHub 仓库 URL - // 支持格式: https://github.com/owner/repo 或 https://github.com/owner/repo.git - const githubRepoMatch = repo.match(/github\.com[:/]([^/]+)\/([^/.]+)/); + // 支持格式: + // - owner/repo (简短格式,来自市场) + // - github.com/owner/repo + // - https://github.com/owner/repo + // - https://github.com/owner/repo.git + // - git@github.com:owner/repo.git + const githubRepoMatch = repo.match(/(?:github\.com[:/]|^)([^/]+)\/([^/\s#]+?)(?:\.git)?$/); if (!githubRepoMatch) { reply.status(400).send({ error: "Invalid GitHub repository URL" }); return; @@ -484,7 +484,6 @@ export const createServer = async (config: any): Promise => { // 辅助函数:从 ZIP 加载预设 async function loadPresetFromZip(zipFile: string): Promise { - const AdmZip = (await import('adm-zip')).default; const zip = new AdmZip(zipFile); // 首先尝试在根目录查找 manifest.json diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0c969bf..a00fa61 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,3 +8,4 @@ export * from './preset/install'; export * from './preset/export'; export * from './preset/readPreset'; export * from './preset/schema'; + diff --git a/packages/shared/src/preset/install.ts b/packages/shared/src/preset/install.ts index b4e87e3..aeb8364 100644 --- a/packages/shared/src/preset/install.ts +++ b/packages/shared/src/preset/install.ts @@ -4,12 +4,11 @@ */ 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'; +import { PresetFile, MergeStrategy, RequiredInput, ManifestFile, PresetInfo } from './types'; +import { HOME_DIR, PRESETS_DIR } from '../constants'; /** * 获取预设目录的完整路径 @@ -48,6 +47,58 @@ export async function extractPreset(sourceZip: string, targetDir: string): Promi // 解压文件 const zip = new AdmZip(sourceZip); + const entries = zip.getEntries(); + + // 检测是否有单一的根目录(GitHub ZIP 文件通常有这个特征) + if (entries.length > 0) { + // 获取所有顶层目录 + const rootDirs = new Set(); + for (const entry of entries) { + const parts = entry.entryName.split('/'); + if (parts.length > 1) { + rootDirs.add(parts[0]); + } + } + + // 如果只有一个根目录,则去除它 + if (rootDirs.size === 1) { + const singleRoot = Array.from(rootDirs)[0]; + + // 检查 manifest.json 是否在根目录下 + const hasManifestInRoot = entries.some(e => + e.entryName === 'manifest.json' || e.entryName.startsWith(`${singleRoot}/manifest.json`) + ); + + if (hasManifestInRoot) { + // 将所有文件从根目录下提取出来 + for (const entry of entries) { + if (entry.isDirectory) { + continue; + } + + // 去除根目录前缀 + let newPath = entry.entryName; + if (newPath.startsWith(`${singleRoot}/`)) { + newPath = newPath.substring(singleRoot.length + 1); + } + + // 跳过根目录本身 + if (newPath === '' || newPath === singleRoot) { + continue; + } + + // 提取文件 + const targetPath = path.join(targetDir, newPath); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, entry.getData()); + } + + return; + } + } + } + + // 如果没有单一的根目录,直接解压 zip.extractAllTo(targetDir, true); } @@ -65,11 +116,11 @@ export async function readManifestFromDir(presetDir: string): Promise { return false; } } + +/** + * 列出所有已安装的预设 + * @returns PresetInfo 数组 + */ +export async function listPresets(): Promise { + const presetsDir = PRESETS_DIR; + const presets: PresetInfo[] = []; + + try { + await fs.access(presetsDir); + } catch { + return presets; + } + + // 读取目录下的所有子目录 + const entries = await fs.readdir(presetsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const presetName = entry.name; + const presetDir = path.join(presetsDir, presetName); + const manifestPath = path.join(presetDir, 'manifest.json'); + + try { + // 检查 manifest.json 是否存在 + await fs.access(manifestPath); + + // 读取 manifest.json + const content = await fs.readFile(manifestPath, 'utf-8'); + const manifest = JSON5.parse(content) as ManifestFile; + + // 获取目录创建时间 + const stats = await fs.stat(presetDir); + + presets.push({ + name: manifest.name || presetName, + version: manifest.version, + description: manifest.description, + author: manifest.author, + config: manifestToPresetFile(manifest).config, + }); + } catch { + // 忽略无效的预设目录(没有 manifest.json 或读取失败) + // 可以选择跳过或者添加到列表中标记为错误 + continue; + } + } + } + + return presets; +} diff --git a/packages/shared/src/preset/merge.ts b/packages/shared/src/preset/merge.ts index 3b3f401..adaa1e1 100644 --- a/packages/shared/src/preset/merge.ts +++ b/packages/shared/src/preset/merge.ts @@ -2,72 +2,24 @@ * 配置合并策略 */ -import { MergeStrategy, ProviderConfig, RouterConfig, TransformerConfig, ProviderConflictAction } from './types'; +import { MergeStrategy, ProviderConfig, RouterConfig, TransformerConfig } from './types'; /** * 合并 Provider 配置 + * 如果 provider 已存在则直接覆盖,否则添加 */ -async function mergeProviders( +function mergeProviders( existing: ProviderConfig[], - incoming: ProviderConfig[], - strategy: MergeStrategy, - onProviderConflict?: (providerName: string) => Promise -): Promise { + incoming: ProviderConfig[] +): ProviderConfig[] { const result = [...existing]; - const existingNames = new Set(existing.map(p => p.name)); + const existingNames = new Map(existing.map(p => [p.name, result.findIndex(x => x.name === 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; - } + const existingIndex = existingNames.get(provider.name); + if (existingIndex !== undefined) { + // Provider 已存在,直接覆盖 + result[existingIndex] = provider; } else { // 新 Provider,直接添加 result.push(provider); @@ -216,7 +168,6 @@ async function mergeOtherConfig( * 合并交互回调接口 */ export interface MergeCallbacks { - onProviderConflict?: (providerName: string) => Promise; onRouterConflict?: (key: string, existingValue: any, newValue: any) => Promise; onTransformerConflict?: (transformerPath: string) => Promise<'keep' | 'overwrite' | 'skip'>; onConfigConflict?: (key: string) => Promise; @@ -240,11 +191,9 @@ export async function mergeConfig( // 合并 Providers if (presetConfig.Providers) { - result.Providers = await mergeProviders( + result.Providers = mergeProviders( result.Providers || [], - presetConfig.Providers, - strategy, - callbacks?.onProviderConflict + presetConfig.Providers ); } diff --git a/packages/shared/src/preset/sensitiveFields.ts b/packages/shared/src/preset/sensitiveFields.ts index 0d964c1..f314ce6 100644 --- a/packages/shared/src/preset/sensitiveFields.ts +++ b/packages/shared/src/preset/sensitiveFields.ts @@ -128,9 +128,9 @@ function sanitizeObject( sanitizedObj[key] = value; // 仍然需要记录为必需输入,但使用已有环境变量 const envVarName = extractEnvVarName(value); - if (envVarName && !requiredInputs.some(input => input.field === currentPath)) { + if (envVarName && !requiredInputs.some(input => input.id === currentPath)) { requiredInputs.push({ - field: currentPath, + id: currentPath, prompt: `Enter ${key}`, placeholder: envVarName, }); @@ -163,7 +163,7 @@ function sanitizeObject( // 记录为必需输入 requiredInputs.push({ - field: currentPath, + id: currentPath, prompt: `Enter ${key}`, placeholder: envVarName, }); diff --git a/packages/shared/src/preset/types.ts b/packages/shared/src/preset/types.ts index 0f75d05..9d0fc55 100644 --- a/packages/shared/src/preset/types.ts +++ b/packages/shared/src/preset/types.ts @@ -140,12 +140,6 @@ 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; @@ -243,5 +237,11 @@ export interface SanitizeResult { sanitizedCount: number; } -// Provider 冲突处理动作 -export type ProviderConflictAction = 'keep' | 'overwrite' | 'merge' | 'skip'; +// Preset 信息(用于列表展示) +export interface PresetInfo { + name: string; // 预设名称 + version?: string; // 版本号 + description?: string; // 描述 + author?: string; // 作者 + config: PresetConfigSection; +} diff --git a/packages/ui/package.json b/packages/ui/package.json index fe350ca..5c98a3f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,9 +11,11 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.13", diff --git a/packages/ui/src/components/Presets.tsx b/packages/ui/src/components/Presets.tsx index 02be691..f61c3a1 100644 --- a/packages/ui/src/components/Presets.tsx +++ b/packages/ui/src/components/Presets.tsx @@ -144,10 +144,45 @@ export function Presets() { const handleInstallFromMarket = async (preset: MarketPreset) => { try { setInstallingFromMarket(preset.id); + + // 第一步:安装预设(解压到目录) await api.installPresetFromGitHub(preset.repo, preset.name); - setToast({ message: t('presets.preset_installed'), type: 'success' }); - setMarketDialogOpen(false); - await loadPresets(); + + // 第二步:获取预设详情(检查是否需要配置) + try { + const detail = await api.getPreset(preset.name); + const presetDetail: PresetDetail = { ...preset, ...detail }; + + // 检查是否需要配置 + if (detail.schema && detail.schema.length > 0) { + // 需要配置,打开配置对话框 + setSelectedPreset(presetDetail); + + // 初始化默认值 + const initialValues: Record = {}; + for (const input of detail.schema) { + initialValues[input.id] = input.defaultValue ?? ''; + } + setSecrets(initialValues); + + // 关闭市场对话框,打开详情对话框 + setMarketDialogOpen(false); + setDetailDialogOpen(true); + + setToast({ message: t('presets.preset_installed_config_required'), type: 'warning' }); + } else { + // 不需要配置,直接完成 + setToast({ message: t('presets.preset_installed'), type: 'success' }); + setMarketDialogOpen(false); + await loadPresets(); + } + } catch (error) { + // 获取详情失败,但安装成功了,刷新列表 + console.error('Failed to get preset details after installation:', error); + 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' }); @@ -214,21 +249,81 @@ export function Presets() { 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' }); + // 验证输入 + if (installMethod === 'url' && !installUrl) { + setToast({ message: t('presets.please_provide_url'), type: 'warning' }); + return; + } + if (installMethod === 'file' && !installFile) { + setToast({ message: t('presets.please_provide_file'), type: 'warning' }); return; } - setToast({ message: t('presets.preset_installed'), type: 'success' }); - setInstallDialogOpen(false); - setInstallUrl(''); - setInstallFile(null); - setInstallName(''); - await loadPresets(); + // 确定预设名称 + const presetName = installName || ( + installMethod === 'file' + ? installFile!.name.replace('.ccrsets', '') + : installUrl!.split('/').pop()!.replace('.ccrsets', '') + ); + + // 第一步:安装预设(解压到目录) + if (installMethod === 'url' && installUrl) { + await api.installPresetFromUrl(installUrl, presetName); + } else if (installMethod === 'file' && installFile) { + await api.uploadPresetFile(installFile, presetName); + } else { + return; + } + + // 第二步:获取预设详情(检查是否需要配置) + try { + const detail = await api.getPreset(presetName); + + // 检查是否需要配置 + if (detail.schema && detail.schema.length > 0) { + // 需要配置,打开配置对话框 + setSelectedPreset({ + id: presetName, + name: presetName, + version: detail.version || '1.0.0', + installed: true, + ...detail + }); + + // 初始化默认值 + const initialValues: Record = {}; + for (const input of detail.schema) { + initialValues[input.id] = input.defaultValue ?? ''; + } + setSecrets(initialValues); + + // 关闭安装对话框,打开详情对话框 + setInstallDialogOpen(false); + setInstallUrl(''); + setInstallFile(null); + setInstallName(''); + setDetailDialogOpen(true); + + setToast({ message: t('presets.preset_installed_config_required'), type: 'warning' }); + } else { + // 不需要配置,直接完成 + setToast({ message: t('presets.preset_installed'), type: 'success' }); + setInstallDialogOpen(false); + setInstallUrl(''); + setInstallFile(null); + setInstallName(''); + await loadPresets(); + } + } catch (error) { + // 获取详情失败,但安装成功了,刷新列表 + console.error('Failed to get preset details after installation:', error); + 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' }); @@ -262,6 +357,8 @@ export function Presets() { setToast({ message: t('presets.preset_applied'), type: 'success' }); setDetailDialogOpen(false); setSecrets({}); + // 刷新预设列表 + await loadPresets(); } catch (error: any) { console.error('Failed to apply preset:', error); setToast({ message: t('presets.preset_apply_failed', { error: error.message }), type: 'error' }); @@ -443,7 +540,7 @@ export function Presets() { )} -
+
{selectedPreset?.description && (

{selectedPreset.description}

)} diff --git a/packages/ui/src/components/preset/DynamicConfigForm.tsx b/packages/ui/src/components/preset/DynamicConfigForm.tsx index c34068b..242f18e 100644 --- a/packages/ui/src/components/preset/DynamicConfigForm.tsx +++ b/packages/ui/src/components/preset/DynamicConfigForm.tsx @@ -87,7 +87,7 @@ export function DynamicConfigForm({ const visible = new Set(); for (const field of schema) { - if (shouldShowField(field, values)) { + if (shouldShowField(field)) { visible.add(field.id); } } @@ -334,7 +334,7 @@ export function DynamicConfigForm({ {field.type === 'select' && (