Merge pull request #164 from farion1231/refactor/project-restructure
comprehensive architectural refactoring
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [3.5.0] - 2025-01-15
|
## [3.5.0] - 2025-01-15
|
||||||
|
|
||||||
|
### ⚠ Breaking Changes
|
||||||
|
|
||||||
|
- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。
|
||||||
|
- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`。
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system
|
- **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system
|
||||||
@@ -248,3 +253,17 @@ For users upgrading from v2.x (Electron version):
|
|||||||
- Basic provider management
|
- Basic provider management
|
||||||
- Claude Code integration
|
- Claude Code integration
|
||||||
- Configuration file handling
|
- Configuration file handling
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### ⚠️ Breaking Changes
|
||||||
|
|
||||||
|
- Tauri 命令统一仅接受 `app` 参数,移除历史 `app_type`/`appType` 兼容路径;传入未知 `app` 时会明确报错,并提示可选值。
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
|
||||||
|
- 统一 `AppType` 解析:集中到 `FromStr` 实现,命令层不再各自实现 `parse_app()`,减少重复与漂移。
|
||||||
|
- 错误消息本地化与更友好:对不支持的 `app` 返回中英双语提示,并包含可选值清单。
|
||||||
|
|
||||||
|
### 🧪 Tests
|
||||||
|
|
||||||
|
- 新增单元测试覆盖 `AppType::from_str`:大小写、裁剪空白、未知值错误消息。
|
||||||
|
|||||||
71
README.md
71
README.md
@@ -1,13 +1,11 @@
|
|||||||
# Claude Code & Codex 供应商切换器
|
# Claude Code & Codex 供应商切换器
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
|
|
||||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||||
|
|
||||||
> **📢 重要通知**:CC Switch 即将进行大规模重构,请暂缓提交新的 PR,感谢理解与配合!
|
|
||||||
|
|
||||||
> v3.5.0 :新增 **MCP 管理**、**配置导入/导出**、**端点速度测试**功能,完善国际化覆盖,新增 Longcat、kat-coder 预设,标准化发布文件命名规范。
|
> v3.5.0 :新增 **MCP 管理**、**配置导入/导出**、**端点速度测试**功能,完善国际化覆盖,新增 Longcat、kat-coder 预设,标准化发布文件命名规范。
|
||||||
|
|
||||||
> v3.4.0 :新增 i18next 国际化(还有部分未完成)、对新模型(qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp)的支持、Claude 插件、单实例守护、托盘最小化及安装器优化等。
|
> v3.4.0 :新增 i18next 国际化(还有部分未完成)、对新模型(qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp)的支持、Claude 插件、单实例守护、托盘最小化及安装器优化等。
|
||||||
@@ -97,6 +95,15 @@ brew upgrade --cask cc-switch
|
|||||||
4. 重启或新开终端以确保生效
|
4. 重启或新开终端以确保生效
|
||||||
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
|
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
|
||||||
|
|
||||||
|
### MCP 配置说明(v3.5.x)
|
||||||
|
|
||||||
|
- 管理位置:所有 MCP 服务器定义集中保存在 `~/.cc-switch/config.json`(按客户端 `claude` / `codex` 分类)
|
||||||
|
- 同步机制:
|
||||||
|
- 启用的 Claude MCP 会投影到 `~/.claude.json`(路径可随覆盖目录而变化)
|
||||||
|
- 启用的 Codex MCP 会投影到 `~/.codex/config.toml`
|
||||||
|
- 校验与归一化:新增/导入时自动校验字段合法性(stdio/http),并自动修复/填充 `id` 等键名
|
||||||
|
- 导入来源:支持从 `~/.claude.json` 与 `~/.codex/config.toml` 导入;已存在条目只强制 `enabled=true`,不覆盖其他字段
|
||||||
|
|
||||||
### 检查更新
|
### 检查更新
|
||||||
|
|
||||||
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
|
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
|
||||||
@@ -137,6 +144,32 @@ brew upgrade --cask cc-switch
|
|||||||
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
|
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
|
||||||
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
|
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
|
||||||
|
|
||||||
|
## 架构总览(v3.5.x)
|
||||||
|
|
||||||
|
- 前端(Renderer)
|
||||||
|
- 技术栈:TypeScript + React 18 + Vite + TailwindCSS
|
||||||
|
- 数据层:TanStack React Query 统一查询与变更(`@/lib/query`),Tauri API 统一封装(`@/lib/api`)
|
||||||
|
- 事件流:监听后端 `provider-switched` 事件,驱动 UI 刷新与托盘状态一致
|
||||||
|
- 组织结构:按领域拆分组件(providers/settings/mcp),动作逻辑下沉至 Hooks(如 `useProviderActions`)
|
||||||
|
|
||||||
|
- 后端(Tauri + Rust)
|
||||||
|
- Commands(接口层):`src-tauri/src/commands/*` 按领域拆分(provider/config/mcp 等)
|
||||||
|
- Services(业务层):`src-tauri/src/services/*` 承载核心逻辑(Provider/MCP/Config/Speedtest)
|
||||||
|
- 模型与状态:`provider.rs`(领域模型)+ `app_config.rs`(多应用配置)+ `store.rs`(全局 RwLock)
|
||||||
|
- 可靠性:
|
||||||
|
- 统一错误类型 `AppError`(包含本地化消息)
|
||||||
|
- 事务式变更(配置快照 + 失败回滚)与原子写入(避免半写入)
|
||||||
|
- 托盘菜单与事件:切换后重建菜单并向前端发射 `provider-switched` 事件
|
||||||
|
|
||||||
|
- 设计要点(SSOT)
|
||||||
|
- 单一事实源:供应商配置集中存放于 `~/.cc-switch/config.json`
|
||||||
|
- 切换时仅写 live 配置(Claude: `settings.json`;Codex: `auth.json` + `config.toml`)
|
||||||
|
- 首次缺省导入:当某应用无任何供应商时,会从已有 live 配置生成默认项
|
||||||
|
|
||||||
|
- 兼容性与变更
|
||||||
|
- 命令参数统一:Tauri 命令仅接受 `app`(值为 `claude` / `codex`)
|
||||||
|
- 前端类型统一:使用 `AppId` 表达应用标识(替代历史 `AppType` 导出)
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
### 环境要求
|
### 环境要求
|
||||||
@@ -164,6 +197,12 @@ pnpm format
|
|||||||
# 检查代码格式
|
# 检查代码格式
|
||||||
pnpm format:check
|
pnpm format:check
|
||||||
|
|
||||||
|
# 运行前端单元测试
|
||||||
|
pnpm test:unit
|
||||||
|
|
||||||
|
# 监听模式运行测试
|
||||||
|
pnpm test:unit:watch
|
||||||
|
|
||||||
# 构建应用
|
# 构建应用
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
@@ -193,18 +232,27 @@ cargo test
|
|||||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||||
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
||||||
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端)
|
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端)
|
||||||
|
- **[TanStack Query](https://tanstack.com/query/latest)** - 前端数据获取与缓存
|
||||||
|
- **[i18next](https://www.i18next.com/)** - 国际化框架
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── src/ # 前端代码 (React + TypeScript)
|
├── src/ # 前端代码 (React + TypeScript)
|
||||||
│ ├── components/ # React 组件
|
│ ├── components/ # React 组件(providers/settings/mcp/ui 等)
|
||||||
│ ├── config/ # 预设供应商配置
|
│ ├── hooks/ # 领域动作与状态(如 useProviderActions)
|
||||||
│ ├── lib/ # Tauri API 封装
|
│ ├── lib/
|
||||||
│ └── utils/ # 工具函数
|
│ │ ├── api/ # Tauri API 封装(providers/settings/mcp 等)
|
||||||
|
│ │ └── query/ # TanStack Query 查询/变更与 client
|
||||||
|
│ ├── i18n/ # 国际化资源
|
||||||
|
│ ├── config/ # 供应商/MCP 预设
|
||||||
|
│ └── utils/ # 工具函数
|
||||||
├── src-tauri/ # 后端代码 (Rust)
|
├── src-tauri/ # 后端代码 (Rust)
|
||||||
│ ├── src/ # Rust 源代码
|
│ ├── src/ # Rust 源代码
|
||||||
│ │ ├── commands.rs # Tauri 命令定义
|
│ │ ├── commands/ # Tauri 命令定义(按域拆分)
|
||||||
|
│ │ ├── services/ # 领域服务(Provider/MCP/Speedtest 等)
|
||||||
|
│ │ ├── mcp.rs # MCP 同步与规范化
|
||||||
|
│ │ ├── migration.rs # 配置迁移逻辑
|
||||||
│ │ ├── config.rs # 配置文件管理
|
│ │ ├── config.rs # 配置文件管理
|
||||||
│ │ ├── provider.rs # 供应商管理逻辑
|
│ │ ├── provider.rs # 供应商管理逻辑
|
||||||
│ │ └── store.rs # 状态管理
|
│ │ └── store.rs # 状态管理
|
||||||
@@ -227,6 +275,11 @@ cargo test
|
|||||||
|
|
||||||
欢迎提交 Issue 反馈问题和建议!
|
欢迎提交 Issue 反馈问题和建议!
|
||||||
|
|
||||||
|
提交 PR 前请确保:
|
||||||
|
- 通过类型检查:`pnpm typecheck`
|
||||||
|
- 通过格式检查:`pnpm format:check`
|
||||||
|
- 通过单元测试:`pnpm test:unit`
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ src/
|
|||||||
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
||||||
- ✅ ProviderList.tsx - 供应商列表
|
- ✅ ProviderList.tsx - 供应商列表
|
||||||
- ✅ LanguageSwitcher.tsx - 语言切换器
|
- ✅ LanguageSwitcher.tsx - 语言切换器
|
||||||
- 🔄 SettingsModal.tsx - 设置弹窗(部分完成)
|
- ✅ settings/SettingsDialog.tsx - 设置对话框
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
|
|||||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
169
docs/BACKEND_REFACTOR_PLAN.md
Normal file
169
docs/BACKEND_REFACTOR_PLAN.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# CC Switch Rust 后端重构方案
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
- [背景与现状](#背景与现状)
|
||||||
|
- [问题确认](#问题确认)
|
||||||
|
- [方案评估](#方案评估)
|
||||||
|
- [渐进式重构路线](#渐进式重构路线)
|
||||||
|
- [测试策略](#测试策略)
|
||||||
|
- [风险与对策](#风险与对策)
|
||||||
|
- [总结](#总结)
|
||||||
|
|
||||||
|
## 背景与现状
|
||||||
|
- 前端已完成重构,后端 (Tauri + Rust) 仍维持历史结构。
|
||||||
|
- 核心文件集中在 `src-tauri/src/commands.rs`、`lib.rs` 等超大文件中,业务逻辑与界面事件耦合严重。
|
||||||
|
- 测试覆盖率低,只有零散单元测试,缺乏集成验证。
|
||||||
|
|
||||||
|
## 问题确认
|
||||||
|
|
||||||
|
| 提案问题 | 实际情况 | 严重程度 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `commands.rs` 过长 | ✅ 1526 行,包含 32 个命令,职责混杂 | 🔴 高 |
|
||||||
|
| `lib.rs` 缺少服务层 | ✅ 541 行,托盘/事件/业务逻辑耦合 | 🟡 中 |
|
||||||
|
| `Result<T, String>` 泛滥 | ✅ 118 处,错误上下文丢失 | 🟡 中 |
|
||||||
|
| 全局 `Mutex` 阻塞 | ✅ 31 处 `.lock()` 调用,读写不分离 | 🟡 中 |
|
||||||
|
| 配置逻辑分散 | ✅ 分布在 5 个文件 (`config`/`app_config`/`app_store`/`settings`/`codex_config`) | 🟢 低 |
|
||||||
|
|
||||||
|
代码规模分布(约 5.4k SLOC):
|
||||||
|
- `commands.rs`: 1526 行(28%)→ 第一优先级 🎯
|
||||||
|
- `lib.rs`: 541 行(10%)→ 托盘逻辑与业务耦合
|
||||||
|
- `mcp.rs`: 732 行(14%)→ 相对清晰
|
||||||
|
- `migration.rs`: 431 行(8%)→ 一次性逻辑
|
||||||
|
- 其他文件合计:2156 行(40%)
|
||||||
|
|
||||||
|
## 方案评估
|
||||||
|
|
||||||
|
### ✅ 优点
|
||||||
|
1. **分层架构清晰**
|
||||||
|
- `commands/`:Tauri 命令薄层
|
||||||
|
- `services/`:业务流程,如供应商切换、MCP 同步
|
||||||
|
- `infrastructure/`:配置读写、外设交互
|
||||||
|
- `domain/`:数据模型 (`Provider`, `AppType` 等)
|
||||||
|
→ 提升可测试性、降低耦合度、方便团队协作。
|
||||||
|
|
||||||
|
2. **统一错误处理**
|
||||||
|
- 引入 `AppError`(`thiserror`),保留错误链和上下文。
|
||||||
|
- Tauri 命令仍返回 `Result<T, String>`,通过 `From<AppError>` 自动转换。
|
||||||
|
- 改善日志可读性,利于排查。
|
||||||
|
|
||||||
|
3. **并发优化**
|
||||||
|
- `AppState` 切换为 `RwLock<MultiAppConfig>`。
|
||||||
|
- 读多写少的场景提升吞吐(如频繁查询供应商列表)。
|
||||||
|
|
||||||
|
### ⚠️ 风险
|
||||||
|
1. **过度设计**
|
||||||
|
- 完整 DDD 四层在 5k 行项目中会增加 30-50% 维护成本。
|
||||||
|
- Rust trait + repository 样板较多,收益不足。
|
||||||
|
- 推荐“轻量分层”而非正统 DDD。
|
||||||
|
|
||||||
|
2. **迁移成本高**
|
||||||
|
- `commands.rs` 拆分、错误统一、锁改造触及多文件。
|
||||||
|
- 测试缺失导致重构风险高,需先补测试。
|
||||||
|
- 估算完整改造需 5-6 周;建议分阶段输出可落地价值。
|
||||||
|
|
||||||
|
3. **技术选型需谨慎**
|
||||||
|
- `parking_lot` 相比标准库 `RwLock` 提升有限,不必引入。
|
||||||
|
- `spawn_blocking` 仅用于 >100ms 的阻塞任务,避免滥用。
|
||||||
|
- 以现有依赖为主,控制复杂度。
|
||||||
|
|
||||||
|
## 实施进度
|
||||||
|
- **阶段 1:统一错误处理 ✅**
|
||||||
|
- 引入 `thiserror` 并在 `src-tauri/src/error.rs` 定义 `AppError`,提供常用构造函数和 `From<AppError> for String`,保留错误链路。
|
||||||
|
- 配置、存储、同步等核心模块(`config.rs`、`app_config.rs`、`app_store.rs`、`store.rs`、`codex_config.rs`、`claude_mcp.rs`、`claude_plugin.rs`、`import_export.rs`、`mcp.rs`、`migration.rs`、`speedtest.rs`、`usage_script.rs`、`settings.rs`、`lib.rs` 等)已统一返回 `Result<_, AppError>`,避免字符串错误丢失上下文。
|
||||||
|
- Tauri 命令层继续返回 `Result<_, String>`,通过 `?` + `Into<String>` 统一转换,前端无需调整。
|
||||||
|
- `cargo check` 通过,`rg "Result<[^>]+, String"` 巡检确认除命令层外已无字符串错误返回。
|
||||||
|
- **阶段 2:拆分命令层 ✅**
|
||||||
|
- 已将单一 `src-tauri/src/commands.rs` 拆分为 `commands/{provider,mcp,config,settings,misc,plugin}.rs` 并通过 `commands/mod.rs` 统一导出,保持对外 API 不变。
|
||||||
|
- 每个文件聚焦单一功能域(供应商、MCP、配置、设置、杂项、插件),命令函数平均 150-250 行,可读性与后续维护性显著提升。
|
||||||
|
- 相关依赖调整后 `cargo check` 通过,静态巡检确认无重复定义或未注册命令。
|
||||||
|
- **阶段 3:补充测试 ✅**
|
||||||
|
- `tests/import_export_sync.rs` 集成测试涵盖配置备份、Claude/Codex live 同步、MCP 投影与 Codex/Claude 双向导入流程,并新增启用项清理、非法 TOML 抛错等失败场景验证;统一使用隔离 HOME 目录避免污染真实用户环境。
|
||||||
|
- 扩展 `lib.rs` re-export,暴露 `AppType`、`MultiAppConfig`、`AppError`、配置 IO 以及 Codex/Claude MCP 路径与同步函数,方便服务层及测试直接复用核心逻辑。
|
||||||
|
- 新增负向测试验证 Codex 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。
|
||||||
|
- 针对 `codex_config::write_codex_live_atomic` 补充成功与失败场景测试,覆盖 auth/config 原子写入与失败回滚逻辑(模拟目标路径为目录时的 rename 失败),降低 Codex live 写入回归风险。
|
||||||
|
- 新增 `tests/provider_commands.rs` 覆盖 `switch_provider` 的 Codex 正常流程与供应商缺失分支,并抽取 `switch_provider_internal` 以复用 `AppError`,通过 `switch_provider_test_hook` 暴露测试入口;同时共享 `tests/support.rs` 提供隔离 HOME / 互斥工具函数。
|
||||||
|
- 补充 Claude 切换集成测试,验证 live `settings.json` 覆写、新旧供应商快照回填以及 `.cc-switch/config.json` 持久化结果,确保阶段四提取服务层时拥有可回归的用例。
|
||||||
|
- 增加 Codex 缺失 `auth` 场景测试,确认 `switch_provider_internal` 在关键字段缺失时返回带上下文的 `AppError`,同时保持内存状态未被污染。
|
||||||
|
- 为配置导入命令抽取复用逻辑 `import_config_from_path` 并补充成功/失败集成测试,校验备份生成、状态同步、JSON 解析与文件缺失等错误回退路径;`export_config_to_file` 亦具备成功/缺失源文件的命令级回归。
|
||||||
|
- 新增 `tests/mcp_commands.rs`,通过测试钩子覆盖 `import_default_config`、`import_mcp_from_claude`、`set_mcp_enabled` 等命令层行为,验证缺失文件/非法 JSON 的错误回滚以及成功路径落盘效果;阶段三目标达成,命令层关键边界已具备回归保障。
|
||||||
|
- **阶段 4:服务层抽象 🚧(进行中)**
|
||||||
|
- 新增 `services/provider.rs` 并实现 `ProviderService::switch` / `delete`,集中处理供应商切换、回填、MCP 同步等核心业务;命令层改为薄封装并在 `tests/provider_service.rs`、`tests/provider_commands.rs` 中完成成功与失败路径的集成验证。
|
||||||
|
- 新增 `services/mcp.rs` 提供 `McpService`,封装 MCP 服务器的查询、增删改、启用同步与导入流程;命令层改为参数解析 + 调用服务,`tests/mcp_commands.rs` 直接使用 `McpService` 验证成功与失败路径,阶段三测试继续适配。
|
||||||
|
- `McpService` 在内部先复制内存快照、释放写锁,再执行文件同步,避免阶段五升级后的 `RwLock` 在 I/O 场景被长时间占用;`upsert/delete/set_enabled/sync_enabled` 均已修正。
|
||||||
|
- 新增 `services/config.rs` 提供 `ConfigService`,统一处理配置导入导出、备份与 live 同步;命令层迁移至 `commands/import_export.rs`,在落盘操作前释放锁并复用现有集成测试。
|
||||||
|
- 新增 `services/speedtest.rs` 并实现 `SpeedtestService::test_endpoints`,将 URL 校验、超时裁剪与网络请求封装在服务层,命令改为薄封装;补充单元测试覆盖空列表与非法 URL 分支。
|
||||||
|
- 后续可选:应用设置(Store)命令仍较薄,可按需评估是否抽象;当前阶段四核心服务已基本齐备。
|
||||||
|
- **阶段 5:锁与阻塞优化 ✅(首轮)**
|
||||||
|
- `AppState` 已由 `Mutex<MultiAppConfig>` 切换为 `RwLock<MultiAppConfig>`,托盘、命令与测试均按读写语义区分 `read()` / `write()`;`cargo test` 全量通过验证并未破坏现有流程。
|
||||||
|
- 针对高开销 IO 的配置导入/导出命令提取 `load_config_for_import`,并通过 `tauri::async_runtime::spawn_blocking` 将文件读写与备份迁至阻塞线程,保持命令处理线程轻量。
|
||||||
|
- 其余命令梳理后确认仍属轻量同步操作,暂不额外引入 `spawn_blocking`;若后续出现新的长耗时流程,再按同一模式扩展。
|
||||||
|
|
||||||
|
## 渐进式重构路线
|
||||||
|
|
||||||
|
### 阶段 1:统一错误处理(高收益 / 低风险)
|
||||||
|
- 新增 `src-tauri/src/error.rs`,定义 `AppError`。
|
||||||
|
- 底层文件 IO、配置解析等函数返回 `Result<T, AppError>`。
|
||||||
|
- 命令层通过 `?` 自动传播,最终 `.map_err(Into::into)`。
|
||||||
|
- 预估 3-5 天,立即启动。
|
||||||
|
|
||||||
|
### 阶段 2:拆分 `commands.rs`(高收益 / 中风险)
|
||||||
|
- 按业务拆分为 `commands/provider.rs`、`commands/mcp.rs`、`commands/config.rs`、`commands/settings.rs`、`commands/misc.rs`。
|
||||||
|
- `commands/mod.rs` 统一导出和注册。
|
||||||
|
- 文件行数降低到 200-300 行/文件,职责单一。
|
||||||
|
- 预估 5-7 天,可并行进行部分重构。
|
||||||
|
|
||||||
|
### 阶段 3:补充测试(中收益 / 中风险)
|
||||||
|
- 引入 `tests/` 或 `src-tauri/tests/` 集成测试,覆盖供应商切换、MCP 同步、配置迁移。
|
||||||
|
- 使用 `tempfile`/`tempdir` 隔离文件系统,组合少量回归脚本。
|
||||||
|
- 预估 5-7 天,为后续重构提供安全网。
|
||||||
|
|
||||||
|
### 阶段 4:提取轻量服务层(中收益 / 中风险)
|
||||||
|
- 新增 `services/provider_service.rs`、`services/mcp_service.rs`。
|
||||||
|
- 不强制使用 trait;直接以自由函数/结构体实现业务流程。
|
||||||
|
```rust
|
||||||
|
pub struct ProviderService;
|
||||||
|
impl ProviderService {
|
||||||
|
pub fn switch(config: &mut MultiAppConfig, app: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
// 业务流程:验证、回填、落盘、更新 current、触发事件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 命令层负责参数解析,服务层处理业务逻辑,托盘逻辑重用同一接口。
|
||||||
|
- 预估 7-10 天,可在测试补齐后执行。
|
||||||
|
|
||||||
|
### 阶段 5:锁与阻塞优化(低收益 / 低风险)
|
||||||
|
- ✅ `AppState` 已从 `Mutex` 切换为 `RwLock`,命令与托盘读写按需区分,现有测试全部通过。
|
||||||
|
- ✅ 配置导入/导出命令通过 `spawn_blocking` 处理高开销文件 IO;其他命令维持同步执行以避免不必要调度。
|
||||||
|
- 🔄 持续监控:若后续引入新的批量迁移或耗时任务,再按相同模式扩展到阻塞线程;观察运行时锁竞争情况,必要时考虑进一步拆分状态或引入缓存。
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
- **优先覆盖场景**
|
||||||
|
- 供应商切换:状态更新 + live 配置同步
|
||||||
|
- MCP 同步:enabled 服务器快照与落盘
|
||||||
|
- 配置迁移:归档、备份与版本升级
|
||||||
|
- **推荐结构**
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod integration {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_updates_live_config() { /* ... */ }
|
||||||
|
#[test]
|
||||||
|
fn sync_mcp_to_codex_updates_claude_config() { /* ... */ }
|
||||||
|
#[test]
|
||||||
|
fn migration_preserves_backup() { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 目标覆盖率:关键路径 >80%,文件 IO/迁移 >70%。
|
||||||
|
|
||||||
|
## 风险与对策
|
||||||
|
- **测试不足** → 阶段 3 强制补齐,建立基础集成测试。
|
||||||
|
- **重构跨度大** → 按阶段在独立分支推进(如 `refactor/backend-step1` 等)。
|
||||||
|
- **回滚困难** → 每阶段结束打 tag(如 `v3.6.0-backend-step1`),保留回滚点。
|
||||||
|
- **功能回归** → 重构后执行手动冒烟流程:供应商切换、托盘操作、MCP 同步、配置导入导出。
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
- 当前规模下不建议整体引入完整 DDD/四层架构,避免过度设计。
|
||||||
|
- 建议遵循“错误统一 → 命令拆分 → 补测试 → 服务层抽象 → 锁优化”的渐进式策略。
|
||||||
|
- 完成阶段 1-3 后即可显著提升可维护性与可靠性;阶段 4-5 可根据资源灵活安排。
|
||||||
|
- 重构过程中同步维护文档与测试,确保团队成员对架构演进保持一致认知。
|
||||||
490
docs/REFACTORING_CHECKLIST.md
Normal file
490
docs/REFACTORING_CHECKLIST.md
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
# CC Switch 重构实施清单
|
||||||
|
|
||||||
|
> 用于跟踪重构进度的详细检查清单
|
||||||
|
|
||||||
|
**开始日期**: ___________
|
||||||
|
**预计完成**: ___________
|
||||||
|
**当前阶段**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 0: 准备阶段 (预计 1 天)
|
||||||
|
|
||||||
|
### 环境准备
|
||||||
|
|
||||||
|
- [ ] 创建新分支 `refactor/modernization`
|
||||||
|
- [ ] 创建备份标签 `git tag backup-before-refactor`
|
||||||
|
- [ ] 备份用户配置文件 `~/.cc-switch/config.json`
|
||||||
|
- [ ] 通知团队成员重构开始
|
||||||
|
|
||||||
|
### 依赖安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @tanstack/react-query
|
||||||
|
pnpm add react-hook-form @hookform/resolvers
|
||||||
|
pnpm add zod
|
||||||
|
pnpm add sonner
|
||||||
|
pnpm add next-themes
|
||||||
|
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu
|
||||||
|
pnpm add @radix-ui/react-label @radix-ui/react-select
|
||||||
|
pnpm add @radix-ui/react-slot @radix-ui/react-switch @radix-ui/react-tabs
|
||||||
|
pnpm add class-variance-authority clsx tailwind-merge tailwindcss-animate
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 安装核心依赖 (上述命令)
|
||||||
|
- [ ] 验证依赖安装成功 `pnpm install`
|
||||||
|
- [ ] 验证编译通过 `pnpm typecheck`
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
- [ ] 创建 `components.json`
|
||||||
|
- [ ] 更新 `tsconfig.json` 添加路径别名
|
||||||
|
- [ ] 更新 `vite.config.mts` 添加路径解析
|
||||||
|
- [ ] 验证开发服务器启动 `pnpm dev`
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 1: 基础设施 (预计 2-3 天)
|
||||||
|
|
||||||
|
### 1.1 工具函数和基础组件
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/utils.ts` (cn 函数)
|
||||||
|
- [ ] 创建 `src/components/ui/button.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/dialog.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/input.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/label.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/textarea.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/select.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/switch.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/tabs.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/sonner.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/form.tsx`
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证所有 UI 组件可以正常导入
|
||||||
|
- [ ] 创建一个测试页面验证组件样式
|
||||||
|
|
||||||
|
### 1.2 Query Client 设置
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/query/queryClient.ts`
|
||||||
|
- [ ] 配置默认选项 (retry, staleTime 等)
|
||||||
|
- [ ] 导出 queryClient 实例
|
||||||
|
|
||||||
|
### 1.3 API 层
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/api/providers.ts`
|
||||||
|
- [ ] getAll
|
||||||
|
- [ ] getCurrent
|
||||||
|
- [ ] add
|
||||||
|
- [ ] update
|
||||||
|
- [ ] delete
|
||||||
|
- [ ] switch
|
||||||
|
- [ ] importDefault
|
||||||
|
- [ ] updateTrayMenu
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/api/settings.ts`
|
||||||
|
- [ ] get
|
||||||
|
- [ ] save
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/api/mcp.ts`
|
||||||
|
- [ ] getConfig
|
||||||
|
- [ ] upsertServer
|
||||||
|
- [ ] deleteServer
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/api/index.ts` (聚合导出)
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证 API 调用不会出现运行时错误
|
||||||
|
- [ ] 确认类型定义正确
|
||||||
|
|
||||||
|
### 1.4 Query Hooks
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/query/queries.ts`
|
||||||
|
- [ ] useProvidersQuery
|
||||||
|
- [ ] useSettingsQuery
|
||||||
|
- [ ] useMcpConfigQuery
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/query/mutations.ts`
|
||||||
|
- [ ] useAddProviderMutation
|
||||||
|
- [ ] useSwitchProviderMutation
|
||||||
|
- [ ] useDeleteProviderMutation
|
||||||
|
- [ ] useUpdateProviderMutation
|
||||||
|
- [ ] useSaveSettingsMutation
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/query/index.ts` (聚合导出)
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 在临时组件中测试每个 hook
|
||||||
|
- [ ] 验证 loading/error 状态正确
|
||||||
|
- [ ] 验证缓存和自动刷新工作
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 2: 核心功能重构 (预计 3-4 天)
|
||||||
|
|
||||||
|
### 2.1 主题系统
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/theme-provider.tsx`
|
||||||
|
- [ ] 创建 `src/components/mode-toggle.tsx`
|
||||||
|
- [ ] 更新 `src/index.css` 添加主题变量
|
||||||
|
- [ ] 删除 `src/hooks/useDarkMode.ts`
|
||||||
|
- [ ] 更新所有组件使用新的主题系统
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证主题切换正常工作
|
||||||
|
- [ ] 验证系统主题跟随功能
|
||||||
|
- [ ] 验证主题持久化
|
||||||
|
|
||||||
|
### 2.2 更新 main.tsx
|
||||||
|
|
||||||
|
- [ ] 引入 QueryClientProvider
|
||||||
|
- [ ] 引入 ThemeProvider
|
||||||
|
- [ ] 添加 Toaster 组件
|
||||||
|
- [ ] 移除旧的 API 导入
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证应用可以正常启动
|
||||||
|
- [ ] 验证 Context 正确传递
|
||||||
|
|
||||||
|
### 2.3 重构 App.tsx
|
||||||
|
|
||||||
|
- [ ] 使用 useProvidersQuery 替代手动状态管理
|
||||||
|
- [ ] 移除所有 loadProviders 相关代码
|
||||||
|
- [ ] 移除手动 notification 状态
|
||||||
|
- [ ] 简化事件监听逻辑
|
||||||
|
- [ ] 更新对话框为新的 Dialog 组件
|
||||||
|
|
||||||
|
**目标**: 将 412 行代码减少到 ~100 行
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证供应商列表正常加载
|
||||||
|
- [ ] 验证切换 Claude/Codex 正常工作
|
||||||
|
- [ ] 验证事件监听正常工作
|
||||||
|
|
||||||
|
### 2.4 重构 ProviderList
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/providers/ProviderList.tsx`
|
||||||
|
- [ ] 使用 mutation hooks 处理操作
|
||||||
|
- [ ] 移除 onNotify prop
|
||||||
|
- [ ] 移除手动状态管理
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证供应商列表渲染
|
||||||
|
- [ ] 验证切换操作
|
||||||
|
- [ ] 验证删除操作
|
||||||
|
|
||||||
|
### 2.5 重构表单系统
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/schemas/provider.ts` (Zod schema)
|
||||||
|
- [ ] 创建 `src/components/providers/ProviderForm.tsx`
|
||||||
|
- [ ] 使用 react-hook-form
|
||||||
|
- [ ] 使用 zodResolver
|
||||||
|
- [ ] 字段级验证
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/providers/AddProviderDialog.tsx`
|
||||||
|
- [ ] 使用新的 Dialog 组件
|
||||||
|
- [ ] 集成 ProviderForm
|
||||||
|
- [ ] 使用 useAddProviderMutation
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/providers/EditProviderDialog.tsx`
|
||||||
|
- [ ] 使用新的 Dialog 组件
|
||||||
|
- [ ] 集成 ProviderForm
|
||||||
|
- [ ] 使用 useUpdateProviderMutation
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证表单验证正常工作
|
||||||
|
- [ ] 验证错误提示显示正确
|
||||||
|
- [ ] 验证提交操作成功
|
||||||
|
- [ ] 验证表单重置功能
|
||||||
|
|
||||||
|
### 2.6 清理旧组件
|
||||||
|
|
||||||
|
- [x] 删除 `src/components/AddProviderModal.tsx`
|
||||||
|
- [x] 删除 `src/components/EditProviderModal.tsx`
|
||||||
|
- [x] 更新所有引用这些组件的地方
|
||||||
|
- [x] 删除 `src/components/ProviderForm.tsx` 及 `src/components/ProviderForm/`
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 3: 设置和辅助功能 (预计 2-3 天)
|
||||||
|
|
||||||
|
### 3.1 重构 SettingsDialog
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/settings/SettingsDialog.tsx`
|
||||||
|
- [ ] 使用 Tabs 组件
|
||||||
|
- [ ] 集成各个设置子组件
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/settings/GeneralSettings.tsx`
|
||||||
|
- [ ] 语言设置
|
||||||
|
- [ ] 配置目录设置
|
||||||
|
- [ ] 其他通用设置
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/settings/AboutSection.tsx`
|
||||||
|
- [ ] 版本信息
|
||||||
|
- [ ] 更新检查
|
||||||
|
- [ ] 链接
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/settings/ImportExportSection.tsx`
|
||||||
|
- [ ] 导入功能
|
||||||
|
- [ ] 导出功能
|
||||||
|
|
||||||
|
**目标**: 将 643 行拆分为 4-5 个小组件,每个 100-150 行
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证设置保存功能
|
||||||
|
- [ ] 验证导入导出功能
|
||||||
|
- [ ] 验证更新检查功能
|
||||||
|
|
||||||
|
### 3.2 重构通知系统
|
||||||
|
|
||||||
|
- [ ] 在所有 mutations 中使用 `toast` 替代 `showNotification`
|
||||||
|
- [ ] 移除 App.tsx 中的 notification 状态
|
||||||
|
- [ ] 移除自定义通知组件
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证成功通知显示
|
||||||
|
- [ ] 验证错误通知显示
|
||||||
|
- [ ] 验证通知自动消失
|
||||||
|
|
||||||
|
### 3.3 重构确认对话框
|
||||||
|
|
||||||
|
- [ ] 更新 `src/components/ConfirmDialog.tsx` 使用新的 Dialog
|
||||||
|
- [ ] 或者直接使用 shadcn/ui 的 AlertDialog
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证删除确认对话框
|
||||||
|
- [ ] 验证其他确认场景
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 4: 清理和优化 (预计 1-2 天)
|
||||||
|
|
||||||
|
### 4.1 移除旧代码
|
||||||
|
|
||||||
|
- [x] 删除 `src/lib/styles.ts`
|
||||||
|
- [x] 从 `src/lib/tauri-api.ts` 移除 `window.api` 绑定
|
||||||
|
- [x] 精简 `src/lib/tauri-api.ts`,只保留事件监听相关
|
||||||
|
- [x] 删除或更新 `src/vite-env.d.ts` 中的过时类型
|
||||||
|
|
||||||
|
### 4.2 代码审查
|
||||||
|
|
||||||
|
- [ ] 检查所有 TODO 注释
|
||||||
|
- [x] 检查是否还有 `window.api` 调用
|
||||||
|
- [ ] 检查是否还有手动状态管理
|
||||||
|
- [x] 统一代码风格
|
||||||
|
|
||||||
|
### 4.3 类型检查
|
||||||
|
|
||||||
|
- [x] 运行 `pnpm typecheck` 确保无错误
|
||||||
|
- [x] 修复所有类型错误
|
||||||
|
- [x] 更新类型定义
|
||||||
|
|
||||||
|
### 4.4 性能优化
|
||||||
|
|
||||||
|
- [ ] 检查是否有不必要的重渲染
|
||||||
|
- [ ] 添加必要的 React.memo
|
||||||
|
- [ ] 优化 Query 缓存配置
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 5: 测试和修复 (预计 2-3 天)
|
||||||
|
|
||||||
|
### 5.1 功能测试
|
||||||
|
|
||||||
|
#### 供应商管理
|
||||||
|
- [ ] 添加供应商 (Claude)
|
||||||
|
- [ ] 添加供应商 (Codex)
|
||||||
|
- [ ] 编辑供应商
|
||||||
|
- [ ] 删除供应商
|
||||||
|
- [ ] 切换供应商
|
||||||
|
- [ ] 导入默认配置
|
||||||
|
|
||||||
|
#### 应用切换
|
||||||
|
- [ ] Claude <-> Codex 切换
|
||||||
|
- [ ] 切换后数据正确加载
|
||||||
|
- [ ] 切换后托盘菜单更新
|
||||||
|
|
||||||
|
#### 设置
|
||||||
|
- [ ] 保存通用设置
|
||||||
|
- [ ] 切换语言
|
||||||
|
- [ ] 配置目录选择
|
||||||
|
- [ ] 导入配置
|
||||||
|
- [ ] 导出配置
|
||||||
|
|
||||||
|
#### UI 交互
|
||||||
|
- [ ] 主题切换 (亮色/暗色)
|
||||||
|
- [ ] 对话框打开/关闭
|
||||||
|
- [ ] 表单验证
|
||||||
|
- [ ] Toast 通知
|
||||||
|
|
||||||
|
#### MCP 管理
|
||||||
|
- [ ] 列表显示
|
||||||
|
- [ ] 添加 MCP
|
||||||
|
- [ ] 编辑 MCP
|
||||||
|
- [ ] 删除 MCP
|
||||||
|
- [ ] 启用/禁用 MCP
|
||||||
|
|
||||||
|
### 5.2 边界情况测试
|
||||||
|
|
||||||
|
- [ ] 空供应商列表
|
||||||
|
- [ ] 无效配置文件
|
||||||
|
- [ ] 网络错误
|
||||||
|
- [ ] 后端错误响应
|
||||||
|
- [ ] 并发操作
|
||||||
|
- [ ] 表单输入边界值
|
||||||
|
|
||||||
|
### 5.3 兼容性测试
|
||||||
|
|
||||||
|
- [ ] Windows 测试
|
||||||
|
- [ ] macOS 测试
|
||||||
|
- [ ] Linux 测试
|
||||||
|
|
||||||
|
### 5.4 性能测试
|
||||||
|
|
||||||
|
- [ ] 100+ 供应商加载速度
|
||||||
|
- [ ] 快速切换供应商
|
||||||
|
- [ ] 内存使用情况
|
||||||
|
- [ ] CPU 使用情况
|
||||||
|
|
||||||
|
### 5.5 Bug 修复
|
||||||
|
|
||||||
|
**Bug 列表** (发现后记录):
|
||||||
|
|
||||||
|
1. ___________
|
||||||
|
- [ ] 已修复
|
||||||
|
- [ ] 已验证
|
||||||
|
|
||||||
|
2. ___________
|
||||||
|
- [ ] 已修复
|
||||||
|
- [ ] 已验证
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 最终检查
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
- [ ] 所有 TypeScript 错误已修复
|
||||||
|
- [ ] 运行 `pnpm format` 格式化代码
|
||||||
|
- [ ] 运行 `pnpm typecheck` 通过
|
||||||
|
- [ ] 代码审查完成
|
||||||
|
|
||||||
|
### 文档更新
|
||||||
|
|
||||||
|
- [ ] 更新 `CLAUDE.md` 反映新架构
|
||||||
|
- [ ] 更新 `README.md` (如有必要)
|
||||||
|
- [ ] 添加 Migration Guide (可选)
|
||||||
|
|
||||||
|
### 性能基准
|
||||||
|
|
||||||
|
记录性能数据:
|
||||||
|
|
||||||
|
**旧版本**:
|
||||||
|
- 启动时间: _____ms
|
||||||
|
- 供应商加载: _____ms
|
||||||
|
- 内存占用: _____MB
|
||||||
|
|
||||||
|
**新版本**:
|
||||||
|
- 启动时间: _____ms
|
||||||
|
- 供应商加载: _____ms
|
||||||
|
- 内存占用: _____MB
|
||||||
|
|
||||||
|
### 代码统计
|
||||||
|
|
||||||
|
**代码行数对比**:
|
||||||
|
|
||||||
|
| 文件 | 旧版本 | 新版本 | 减少 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| App.tsx | 412 | ~100 | -76% |
|
||||||
|
| tauri-api.ts | 712 | ~50 | -93% |
|
||||||
|
| ProviderForm.tsx | 271 | ~150 | -45% |
|
||||||
|
| settings 模块 | 1046 | ~470 (拆分) | -55% |
|
||||||
|
| **总计** | 2038 | ~700 | **-66%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 发布准备
|
||||||
|
|
||||||
|
### Pre-release 测试
|
||||||
|
|
||||||
|
- [ ] 创建 beta 版本 `v4.0.0-beta.1`
|
||||||
|
- [ ] 在测试环境验证
|
||||||
|
- [ ] 收集用户反馈
|
||||||
|
|
||||||
|
### 正式发布
|
||||||
|
|
||||||
|
- [ ] 合并到 main 分支
|
||||||
|
- [ ] 创建 Release Tag `v4.0.0`
|
||||||
|
- [ ] 更新 Changelog
|
||||||
|
- [ ] 发布 GitHub Release
|
||||||
|
- [ ] 通知用户更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 回滚触发条件
|
||||||
|
|
||||||
|
如果出现以下情况,考虑回滚:
|
||||||
|
|
||||||
|
- [ ] 重大功能无法使用
|
||||||
|
- [ ] 用户数据丢失
|
||||||
|
- [ ] 严重性能问题
|
||||||
|
- [ ] 无法修复的兼容性问题
|
||||||
|
|
||||||
|
**回滚命令**:
|
||||||
|
```bash
|
||||||
|
git reset --hard backup-before-refactor
|
||||||
|
# 或
|
||||||
|
git revert <commit-range>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 总结报告
|
||||||
|
|
||||||
|
### 成功指标
|
||||||
|
|
||||||
|
- [ ] 所有现有功能正常工作
|
||||||
|
- [ ] 代码量减少 40%+
|
||||||
|
- [ ] 无用户数据丢失
|
||||||
|
- [ ] 性能未下降
|
||||||
|
|
||||||
|
### 经验教训
|
||||||
|
|
||||||
|
**遇到的主要挑战**:
|
||||||
|
1. ___________
|
||||||
|
2. ___________
|
||||||
|
3. ___________
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. ___________
|
||||||
|
2. ___________
|
||||||
|
3. ___________
|
||||||
|
|
||||||
|
**未来改进**:
|
||||||
|
1. ___________
|
||||||
|
2. ___________
|
||||||
|
3. ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**重构完成日期**: ___________
|
||||||
|
**总耗时**: _____ 天
|
||||||
|
**参与人员**: ___________
|
||||||
1658
docs/REFACTORING_MASTER_PLAN.md
Normal file
1658
docs/REFACTORING_MASTER_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
834
docs/REFACTORING_REFERENCE.md
Normal file
834
docs/REFACTORING_REFERENCE.md
Normal file
@@ -0,0 +1,834 @@
|
|||||||
|
# 重构快速参考指南
|
||||||
|
|
||||||
|
> 常见模式和代码示例的速查表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📑 目录
|
||||||
|
|
||||||
|
1. [React Query 使用](#react-query-使用)
|
||||||
|
2. [react-hook-form 使用](#react-hook-form-使用)
|
||||||
|
3. [shadcn/ui 组件使用](#shadcnui-组件使用)
|
||||||
|
4. [代码迁移示例](#代码迁移示例)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React Query 使用
|
||||||
|
|
||||||
|
### 基础查询
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 定义查询 Hook
|
||||||
|
export const useProvidersQuery = (appId: AppId) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['providers', appId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await providersApi.getAll(appId)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在组件中使用
|
||||||
|
function MyComponent() {
|
||||||
|
const { data, isLoading, error } = useProvidersQuery('claude')
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>
|
||||||
|
if (error) return <div>Error: {error.message}</div>
|
||||||
|
|
||||||
|
return <div>{/* 使用 data */}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutation (变更操作)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 定义 Mutation Hook
|
||||||
|
export const useAddProviderMutation = (appId: AppId) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (provider: Provider) => {
|
||||||
|
return await providersApi.add(provider, appId)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// 重新获取数据
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
|
||||||
|
toast.success('添加成功')
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(`添加失败: ${error.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在组件中使用
|
||||||
|
function AddProviderDialog() {
|
||||||
|
const mutation = useAddProviderMutation('claude')
|
||||||
|
|
||||||
|
const handleSubmit = (data: Provider) => {
|
||||||
|
mutation.mutate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmit(formData)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? '添加中...' : '添加'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 乐观更新
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const useSwitchProviderMutation = (appId: AppId) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (providerId: string) => {
|
||||||
|
return await providersApi.switch(providerId, appId)
|
||||||
|
},
|
||||||
|
// 乐观更新: 在请求发送前立即更新 UI
|
||||||
|
onMutate: async (providerId) => {
|
||||||
|
// 取消正在进行的查询
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['providers', appId] })
|
||||||
|
|
||||||
|
// 保存当前数据(以便回滚)
|
||||||
|
const previousData = queryClient.getQueryData(['providers', appId])
|
||||||
|
|
||||||
|
// 乐观更新
|
||||||
|
queryClient.setQueryData(['providers', appId], (old: any) => ({
|
||||||
|
...old,
|
||||||
|
currentProviderId: providerId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { previousData }
|
||||||
|
},
|
||||||
|
// 如果失败,回滚
|
||||||
|
onError: (err, providerId, context) => {
|
||||||
|
queryClient.setQueryData(['providers', appId], context?.previousData)
|
||||||
|
toast.error('切换失败')
|
||||||
|
},
|
||||||
|
// 无论成功失败,都重新获取数据
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖查询
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 第二个查询依赖第一个查询的结果
|
||||||
|
const { data: providers } = useProvidersQuery(appId)
|
||||||
|
const currentProviderId = providers?.currentProviderId
|
||||||
|
|
||||||
|
const { data: currentProvider } = useQuery({
|
||||||
|
queryKey: ['provider', currentProviderId],
|
||||||
|
queryFn: () => providersApi.getById(currentProviderId!),
|
||||||
|
enabled: !!currentProviderId, // 只有当 ID 存在时才执行
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## react-hook-form 使用
|
||||||
|
|
||||||
|
### 基础表单
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// 定义验证 schema
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, '请输入名称'),
|
||||||
|
email: z.string().email('邮箱格式不正确'),
|
||||||
|
age: z.number().min(18, '年龄必须大于18'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof schema>
|
||||||
|
|
||||||
|
function MyForm() {
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
age: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (data: FormData) => {
|
||||||
|
console.log(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<input {...form.register('name')} />
|
||||||
|
{form.formState.errors.name && (
|
||||||
|
<span>{form.formState.errors.name.message}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit">提交</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 shadcn/ui Form 组件
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
function MyForm() {
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>名称</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="请输入名称" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit">提交</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动态表单验证
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 根据条件动态验证
|
||||||
|
const schema = z.object({
|
||||||
|
type: z.enum(['official', 'custom']),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
// 如果是自定义供应商,必须填写 baseUrl
|
||||||
|
if (data.type === 'custom') {
|
||||||
|
return !!data.baseUrl
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: '自定义供应商必须填写 Base URL',
|
||||||
|
path: ['baseUrl'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 手动触发验证
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function MyForm() {
|
||||||
|
const form = useForm<FormData>()
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
// 验证单个字段
|
||||||
|
await form.trigger('name')
|
||||||
|
|
||||||
|
// 验证多个字段
|
||||||
|
await form.trigger(['name', 'email'])
|
||||||
|
|
||||||
|
// 验证所有字段
|
||||||
|
const isValid = await form.trigger()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <form>...</form>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## shadcn/ui 组件使用
|
||||||
|
|
||||||
|
### Dialog (对话框)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
function MyDialog() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>标题</DialogTitle>
|
||||||
|
<DialogDescription>描述信息</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div>对话框内容</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm}>确认</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Select (选择器)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
function MySelect() {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={setValue}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="option1">选项1</SelectItem>
|
||||||
|
<SelectItem value="option2">选项2</SelectItem>
|
||||||
|
<SelectItem value="option3">选项3</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabs (标签页)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
|
function MyTabs() {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="tab1">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="tab1">标签1</TabsTrigger>
|
||||||
|
<TabsTrigger value="tab2">标签2</TabsTrigger>
|
||||||
|
<TabsTrigger value="tab3">标签3</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="tab1">
|
||||||
|
<div>标签1的内容</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tab2">
|
||||||
|
<div>标签2的内容</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tab3">
|
||||||
|
<div>标签3的内容</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast 通知 (Sonner)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// 成功通知
|
||||||
|
toast.success('操作成功')
|
||||||
|
|
||||||
|
// 错误通知
|
||||||
|
toast.error('操作失败')
|
||||||
|
|
||||||
|
// 加载中
|
||||||
|
const toastId = toast.loading('处理中...')
|
||||||
|
// 完成后更新
|
||||||
|
toast.success('处理完成', { id: toastId })
|
||||||
|
// 或
|
||||||
|
toast.dismiss(toastId)
|
||||||
|
|
||||||
|
// 自定义持续时间
|
||||||
|
toast.success('消息', { duration: 5000 })
|
||||||
|
|
||||||
|
// 带操作按钮
|
||||||
|
toast('确认删除?', {
|
||||||
|
action: {
|
||||||
|
label: '删除',
|
||||||
|
onClick: () => handleDelete(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码迁移示例
|
||||||
|
|
||||||
|
### 示例 1: 状态管理迁移
|
||||||
|
|
||||||
|
**旧代码** (手动状态管理):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [providers, setProviders] = useState<Record<string, Provider>>({})
|
||||||
|
const [currentProviderId, setCurrentProviderId] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await window.api.getProviders(appType)
|
||||||
|
const currentId = await window.api.getCurrentProvider(appType)
|
||||||
|
setProviders(data)
|
||||||
|
setCurrentProviderId(currentId)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [appId])
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (React Query):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { data, isLoading, error } = useProvidersQuery(appId)
|
||||||
|
const providers = data?.providers || {}
|
||||||
|
const currentProviderId = data?.currentProviderId || ''
|
||||||
|
```
|
||||||
|
|
||||||
|
**减少**: 从 20+ 行到 3 行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例 2: 表单验证迁移
|
||||||
|
|
||||||
|
**旧代码** (手动验证):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [nameError, setNameError] = useState('')
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [apiKeyError, setApiKeyError] = useState('')
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
let valid = true
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setNameError('请输入名称')
|
||||||
|
valid = false
|
||||||
|
} else {
|
||||||
|
setNameError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey.trim()) {
|
||||||
|
setApiKeyError('请输入 API Key')
|
||||||
|
valid = false
|
||||||
|
} else if (apiKey.length < 10) {
|
||||||
|
setApiKeyError('API Key 长度不足')
|
||||||
|
valid = false
|
||||||
|
} else {
|
||||||
|
setApiKeyError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (validate()) {
|
||||||
|
// 提交
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
{nameError && <span>{nameError}</span>}
|
||||||
|
|
||||||
|
<input value={apiKey} onChange={e => setApiKey(e.target.value)} />
|
||||||
|
{apiKeyError && <span>{apiKeyError}</span>}
|
||||||
|
|
||||||
|
<button onClick={handleSubmit}>提交</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (react-hook-form + zod):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, '请输入名称'),
|
||||||
|
apiKey: z.string().min(10, 'API Key 长度不足'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="apiKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit">提交</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**减少**: 从 40+ 行到 30 行,且更健壮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例 3: 通知系统迁移
|
||||||
|
|
||||||
|
**旧代码** (自定义通知):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [notification, setNotification] = useState<{
|
||||||
|
message: string
|
||||||
|
type: 'success' | 'error'
|
||||||
|
} | null>(null)
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
const showNotification = (message: string, type: 'success' | 'error') => {
|
||||||
|
setNotification({ message, type })
|
||||||
|
setIsVisible(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(() => setNotification(null), 300)
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{notification && (
|
||||||
|
<div className={`notification ${isVisible ? 'visible' : ''} ${notification.type}`}>
|
||||||
|
{notification.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 其他内容 */}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (Sonner):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// 在需要的地方直接调用
|
||||||
|
toast.success('操作成功')
|
||||||
|
toast.error('操作失败')
|
||||||
|
|
||||||
|
// 在 main.tsx 中只需添加一次
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
|
```
|
||||||
|
|
||||||
|
**减少**: 从 20+ 行到 1 行调用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例 4: 对话框迁移
|
||||||
|
|
||||||
|
**旧代码** (自定义 Modal):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setIsOpen(true)}>打开</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="modal-backdrop" onClick={() => setIsOpen(false)}>
|
||||||
|
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>标题</h2>
|
||||||
|
<button onClick={() => setIsOpen(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* 内容 */}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={() => setIsOpen(false)}>取消</button>
|
||||||
|
<button onClick={handleConfirm}>确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (shadcn/ui Dialog):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setIsOpen(true)}>打开</Button>
|
||||||
|
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>标题</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{/* 内容 */}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleConfirm}>确认</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
- 无需自定义样式
|
||||||
|
- 内置无障碍支持
|
||||||
|
- 自动管理焦点和 ESC 键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例 5: API 调用迁移
|
||||||
|
|
||||||
|
**旧代码** (window.api):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 添加供应商
|
||||||
|
const handleAdd = async (provider: Provider) => {
|
||||||
|
try {
|
||||||
|
await window.api.addProvider(provider, appType)
|
||||||
|
await loadProviders()
|
||||||
|
showNotification('添加成功', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('添加失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (React Query Mutation):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在组件中
|
||||||
|
const addMutation = useAddProviderMutation(appId)
|
||||||
|
|
||||||
|
const handleAdd = (provider: Provider) => {
|
||||||
|
addMutation.mutate(provider)
|
||||||
|
// 成功和错误处理已在 mutation 定义中处理
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
- 自动处理 loading 状态
|
||||||
|
- 统一的错误处理
|
||||||
|
- 自动刷新数据
|
||||||
|
- 更少的样板代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 如何在 mutation 成功后关闭对话框?
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mutation = useAddProviderMutation(appId)
|
||||||
|
|
||||||
|
const handleSubmit = (data: Provider) => {
|
||||||
|
mutation.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsOpen(false) // 关闭对话框
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何在表单中使用异步验证?
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().refine(
|
||||||
|
async (name) => {
|
||||||
|
// 检查名称是否已存在
|
||||||
|
const exists = await checkNameExists(name)
|
||||||
|
return !exists
|
||||||
|
},
|
||||||
|
{ message: '名称已存在' }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何手动刷新 Query 数据?
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// 方式1: 使缓存失效,触发重新获取
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
|
||||||
|
|
||||||
|
// 方式2: 直接刷新
|
||||||
|
queryClient.refetchQueries({ queryKey: ['providers', appId] })
|
||||||
|
|
||||||
|
// 方式3: 更新缓存数据
|
||||||
|
queryClient.setQueryData(['providers', appId], newData)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何在组件外部使用 toast?
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 直接导入并使用即可
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export const someUtil = () => {
|
||||||
|
toast.success('工具函数中的通知')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试技巧
|
||||||
|
|
||||||
|
### React Query DevTools
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在 main.tsx 中添加
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||||
|
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看表单状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const form = useForm()
|
||||||
|
|
||||||
|
// 在开发模式下打印表单状态
|
||||||
|
console.log('Form values:', form.watch())
|
||||||
|
console.log('Form errors:', form.formState.errors)
|
||||||
|
console.log('Is valid:', form.formState.isValid)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能优化建议
|
||||||
|
|
||||||
|
### 1. 避免不必要的重渲染
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 使用 React.memo
|
||||||
|
export const ProviderCard = React.memo(({ provider, onEdit }: Props) => {
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
// 或使用 useMemo
|
||||||
|
const sortedProviders = useMemo(
|
||||||
|
() => Object.values(providers).sort(...),
|
||||||
|
[providers]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Query 配置优化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['providers', appId],
|
||||||
|
queryFn: fetchProviders,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5分钟内不重新获取
|
||||||
|
gcTime: 1000 * 60 * 10, // 10分钟后清除缓存
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 表单性能优化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 使用 mode 控制验证时机
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'onBlur', // 失去焦点时验证
|
||||||
|
// mode: 'onChange', // 每次输入都验证(较慢)
|
||||||
|
// mode: 'onSubmit', // 提交时验证(最快)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**: 将此文档保存在浏览器书签或编辑器中,方便随时查阅!
|
||||||
73
docs/TEST_DEVELOPMENT_PLAN.md
Normal file
73
docs/TEST_DEVELOPMENT_PLAN.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 前端测试开发计划
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
- **背景**:v3.5.0 起前端功能快速扩张(供应商管理、MCP、导入导出、端点测速、国际化),缺失系统化测试导致回归风险与人工验证成本攀升。
|
||||||
|
- **目标**:在 3 个迭代内建立覆盖关键业务的自动化测试体系,形成稳定的手动冒烟流程,并将测试执行纳入 CI/CD。
|
||||||
|
|
||||||
|
## 2. 范围与优先级
|
||||||
|
| 范围 | 内容 | 优先级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 供应商管理 | 列表、排序、预设/自定义表单、切换、复制、删除 | P0 |
|
||||||
|
| 配置导入导出 | JSON 校验、备份、进度反馈、失败回滚 | P0 |
|
||||||
|
| MCP 管理 | 列表、启停、模板、命令校验 | P1 |
|
||||||
|
| 设置面板 | 主题/语言切换、目录设置、关于、更新检查 | P1 |
|
||||||
|
| 端点速度测试 & 使用脚本 | 启动测试、状态指示、脚本保存 | P2 |
|
||||||
|
| 国际化 | 中英切换、缺省文案回退 | P2 |
|
||||||
|
|
||||||
|
## 3. 测试分层策略
|
||||||
|
- **单元测试(Vitest)**:纯函数与 Hook(`useProviderActions`、`useSettingsForm`、`useDragSort`、`useImportExport` 等)验证数据处理、错误分支、排序逻辑。
|
||||||
|
- **组件测试(React Testing Library)**:关键组件(`ProviderList`、`AddProviderDialog`、`SettingsDialog`、`McpPanel`)模拟交互、校验、提示;结合 MSW 模拟 API。
|
||||||
|
- **集成测试(App 级别)**:挂载 `App.tsx`,覆盖应用切换、编辑模式、导入导出回调、语言切换,验证状态同步与 toast 提示。
|
||||||
|
- **端到端测试(Playwright)**:依赖 `pnpm dev:renderer`,串联供应商 CRUD、排序拖拽、MCP 启停、语言切换即时刷新、更新检查跳转。
|
||||||
|
- **手动冒烟**:Tauri 桌面包 + dev server 双通道,验证托盘、系统权限、真实文件写入。
|
||||||
|
|
||||||
|
## 4. 环境与工具
|
||||||
|
- 依赖:Node 18+、pnpm 8+、Vitest、React Testing Library、MSW、Playwright、Testing Library User Event、Playwright Trace Viewer。
|
||||||
|
- 配置要点:
|
||||||
|
- 在 `tsconfig` 中共享别名,Vitest 配合 `vite.config.mts`。
|
||||||
|
- `setupTests.ts` 统一注册 MSW/RTL、自定义 matcher。
|
||||||
|
- Playwright 使用多浏览器矩阵(Chromium 必选,WebKit 可选),并共享 `.env.test`。
|
||||||
|
- Mock `@tauri-apps/api` 与 `providersApi`/`settingsApi`,隔离 Rust 层。
|
||||||
|
|
||||||
|
## 5. 自动化建设里程碑
|
||||||
|
| 周期 | 目标 | 交付 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sprint 1 | Vitest 基础设施、核心 Hook 单测(P0) | `pnpm test:unit`、覆盖率报告、10+ 用例 |
|
||||||
|
| Sprint 2 | 组件/集成测试、MSW Mock 层 | `pnpm test:component`、App 主流程用例 |
|
||||||
|
| Sprint 3 | Playwright E2E、CI 接入 | `pnpm test:e2e`、CI job、冒烟脚本 |
|
||||||
|
| 持续 | 回归用例补齐、视觉比对探索 | Playwright Trace、截图基线 |
|
||||||
|
|
||||||
|
## 6. 用例规划概览
|
||||||
|
- **供应商管理**:新增(预设+自定义)、编辑校验、复制排序、切换失败回退、删除确认、使用脚本保存。
|
||||||
|
- **导入导出**:成功、重复导入、校验失败、备份失败提示、导入后托盘刷新。
|
||||||
|
- **MCP**:模板应用、协议切换(stdio/http)、命令校验、启停状态持久化。
|
||||||
|
- **设置**:主题/语言即时生效、目录路径更新、更新检查按钮外链、关于信息渲染。
|
||||||
|
- **端点速度测试**:触发测试、loading/成功/失败状态、指示器颜色、测速数据排序。
|
||||||
|
- **国际化**:默认中文、切换英文后主界面/对话框文案变化、缺失 key fallback。
|
||||||
|
|
||||||
|
## 7. 数据与 Mock 策略
|
||||||
|
- 在 `tests/fixtures/` 维护标准供应商、MCP、设置数据集。
|
||||||
|
- 使用 MSW 拦截 `providersApi`、`settingsApi`、`providersApi.onSwitched` 等调用;提供延迟/错误注入接口以覆盖异常分支。
|
||||||
|
- Playwright 端提供临时用户目录(`TMP_CC_SWITCH_HOME`)+ 伪配置文件,以验证真实文件交互路径。
|
||||||
|
|
||||||
|
## 8. 质量门禁与指标
|
||||||
|
- 覆盖率目标:单元 ≥75%,分支 ≥70%,逐步提升至 80%+。
|
||||||
|
- CI 阶段:`pnpm typecheck` → `pnpm format:check` → `pnpm test:unit` → `pnpm test:component` → `pnpm test:e2e`(可在 nightly 执行)。
|
||||||
|
- 缺陷处理:修复前补充最小复现测试;E2E 冒烟必须陪跑重大功能发布。
|
||||||
|
|
||||||
|
## 9. 工作流与职责
|
||||||
|
- **测试负责人**:前端工程师轮值;负责测试计划维护、PR 流水线健康。
|
||||||
|
- **开发者职责**:提交功能需附新增/更新测试、列出手动验证步骤、如涉及 UI 提交截图。
|
||||||
|
- **Code Review 检查**:测试覆盖说明、mock 合理性、易读性。
|
||||||
|
|
||||||
|
## 10. 风险与缓解
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Tauri API Mock 难度高 | 单测无法稳定 | 抽象 API 适配层 + MSW 统一模拟 |
|
||||||
|
| Playwright 运行时间长 | CI 变慢 | 拆分冒烟/完整版,冒烟只跑关键路径 |
|
||||||
|
| 国际化文案频繁变化 | 用例脆弱 | 优先断言 data-testid/结构,文案使用翻译 key |
|
||||||
|
|
||||||
|
## 11. 输出与维护
|
||||||
|
- 文档维护者:前端团队;每个版本更新后检查测试覆盖清单。
|
||||||
|
- 交付物:测试报告(CI artifact)、Playwright Trace、覆盖率摘要。
|
||||||
|
- 复盘:每次发布后召开 30 分钟测试复盘,记录缺陷、补齐用例。
|
||||||
31
package.json
31
package.json
@@ -10,20 +10,29 @@
|
|||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\""
|
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest watch"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Jason Young",
|
"author": "Jason Young",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.8.0",
|
"@tauri-apps/cli": "^2.8.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.0.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"cross-fetch": "^4.1.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"msw": "^2.11.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0",
|
||||||
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
@@ -35,20 +44,36 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||||
"@tauri-apps/plugin-process": "^2.0.0",
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
"@tauri-apps/plugin-store": "^2.0.0",
|
"@tauri-apps/plugin-store": "^2.0.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"jsonc-parser": "^3.2.1",
|
"jsonc-parser": "^3.2.1",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.65.0",
|
||||||
"react-i18next": "^16.0.0",
|
"react-i18next": "^16.0.0",
|
||||||
"smol-toml": "^1.4.2",
|
"smol-toml": "^1.4.2",
|
||||||
"tailwindcss": "^4.1.13"
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"zod": "^4.1.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2337
pnpm-lock.yaml
generated
2337
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
37
src-tauri/Cargo.lock
generated
37
src-tauri/Cargo.lock
generated
@@ -585,8 +585,10 @@ dependencies = [
|
|||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1572,7 +1574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
|
checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.4.1",
|
"heck 0.4.1",
|
||||||
"proc-macro-crate 2.0.2",
|
"proc-macro-crate 2.0.0",
|
||||||
"proc-macro-error",
|
"proc-macro-error",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3109,11 +3111,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "2.0.2"
|
version = "2.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
|
checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_datetime 0.6.3",
|
|
||||||
"toml_edit 0.20.2",
|
"toml_edit 0.20.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4875,7 +4876,7 @@ checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned 0.6.9",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.3",
|
"toml_datetime 0.6.11",
|
||||||
"toml_edit 0.20.2",
|
"toml_edit 0.20.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4896,9 +4897,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.6.3"
|
version = "0.6.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -4919,7 +4920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.11.4",
|
"indexmap 2.11.4",
|
||||||
"toml_datetime 0.6.3",
|
"toml_datetime 0.6.11",
|
||||||
"winnow 0.5.40",
|
"winnow 0.5.40",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4932,10 +4933,22 @@ dependencies = [
|
|||||||
"indexmap 2.11.4",
|
"indexmap 2.11.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned 0.6.9",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.3",
|
"toml_datetime 0.6.11",
|
||||||
"winnow 0.5.40",
|
"winnow 0.5.40",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.11.4",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_write",
|
||||||
|
"winnow 0.7.13",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.23.6"
|
version = "0.23.6"
|
||||||
@@ -4957,6 +4970,12 @@ dependencies = [
|
|||||||
"winnow 0.7.13",
|
"winnow 0.7.13",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ rust-version = "1.85.0"
|
|||||||
name = "cc_switch_lib"
|
name = "cc_switch_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
test-hooks = []
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.4.0", features = [] }
|
tauri-build = { version = "2.4.0", features = [] }
|
||||||
|
|
||||||
@@ -31,11 +35,13 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
toml_edit = "0.22"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||||
|
thiserror = "1.0"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
/// MCP 配置:单客户端维度(claude 或 codex 下的一组服务器)
|
/// MCP 配置:单客户端维度(claude 或 codex 下的一组服务器)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -19,6 +20,7 @@ pub struct McpRoot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::provider::ProviderManager;
|
use crate::provider::ProviderManager;
|
||||||
|
|
||||||
/// 应用类型
|
/// 应用类型
|
||||||
@@ -38,11 +40,19 @@ impl AppType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&str> for AppType {
|
impl FromStr for AppType {
|
||||||
fn from(s: &str) -> Self {
|
type Err = AppError;
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"codex" => AppType::Codex,
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
_ => AppType::Claude, // 默认为 Claude
|
let normalized = s.trim().to_lowercase();
|
||||||
|
match normalized.as_str() {
|
||||||
|
"claude" => Ok(AppType::Claude),
|
||||||
|
"codex" => Ok(AppType::Codex),
|
||||||
|
other => Err(AppError::localized(
|
||||||
|
"unsupported_app",
|
||||||
|
format!("不支持的应用标识: '{other}'。可选值: claude, codex。"),
|
||||||
|
format!("Unsupported app id: '{other}'. Allowed: claude, codex."),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +90,7 @@ impl Default for MultiAppConfig {
|
|||||||
|
|
||||||
impl MultiAppConfig {
|
impl MultiAppConfig {
|
||||||
/// 从文件加载配置(处理v1到v2的迁移)
|
/// 从文件加载配置(处理v1到v2的迁移)
|
||||||
pub fn load() -> Result<Self, String> {
|
pub fn load() -> Result<Self, AppError> {
|
||||||
let config_path = get_app_config_path();
|
let config_path = get_app_config_path();
|
||||||
|
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
@@ -89,8 +99,8 @@ impl MultiAppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 尝试读取文件
|
// 尝试读取文件
|
||||||
let content = std::fs::read_to_string(&config_path)
|
let content =
|
||||||
.map_err(|e| format!("读取配置文件失败: {}", e))?;
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
// 检查是否是旧版本格式(v1)
|
// 检查是否是旧版本格式(v1)
|
||||||
if let Ok(v1_config) = serde_json::from_str::<ProviderManager>(&content) {
|
if let Ok(v1_config) = serde_json::from_str::<ProviderManager>(&content) {
|
||||||
@@ -130,11 +140,11 @@ impl MultiAppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 尝试读取v2格式
|
// 尝试读取v2格式
|
||||||
serde_json::from_str::<Self>(&content).map_err(|e| format!("解析配置文件失败: {}", e))
|
serde_json::from_str::<Self>(&content).map_err(|e| AppError::json(&config_path, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置到文件
|
/// 保存配置到文件
|
||||||
pub fn save(&self) -> Result<(), String> {
|
pub fn save(&self) -> Result<(), AppError> {
|
||||||
let config_path = get_app_config_path();
|
let config_path = get_app_config_path();
|
||||||
// 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容
|
// 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
|
|||||||
@@ -3,43 +3,37 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{OnceLock, RwLock};
|
use std::sync::{OnceLock, RwLock};
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// Store 中的键名
|
/// Store 中的键名
|
||||||
const STORE_KEY_APP_CONFIG_DIR: &str = "app_config_dir_override";
|
const STORE_KEY_APP_CONFIG_DIR: &str = "app_config_dir_override";
|
||||||
|
|
||||||
/// 全局缓存的 AppHandle (在应用启动时设置)
|
/// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle
|
||||||
static APP_HANDLE: OnceLock<RwLock<Option<tauri::AppHandle>>> = OnceLock::new();
|
static APP_CONFIG_DIR_OVERRIDE: OnceLock<RwLock<Option<PathBuf>>> = OnceLock::new();
|
||||||
|
|
||||||
/// 设置全局 AppHandle
|
fn override_cache() -> &'static RwLock<Option<PathBuf>> {
|
||||||
pub fn set_app_handle(handle: tauri::AppHandle) {
|
APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(None))
|
||||||
let store = APP_HANDLE.get_or_init(|| RwLock::new(None));
|
}
|
||||||
if let Ok(mut guard) = store.write() {
|
|
||||||
*guard = Some(handle);
|
fn update_cached_override(value: Option<PathBuf>) {
|
||||||
|
if let Ok(mut guard) = override_cache().write() {
|
||||||
|
*guard = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取全局 AppHandle
|
/// 获取缓存中的 app_config_dir 覆盖路径
|
||||||
fn get_app_handle() -> Option<tauri::AppHandle> {
|
|
||||||
let store = APP_HANDLE.get()?;
|
|
||||||
let guard = store.read().ok()?;
|
|
||||||
guard.as_ref().cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Tauri Store 读取 app_config_dir 覆盖配置 (无需 AppHandle 版本)
|
|
||||||
pub fn get_app_config_dir_override() -> Option<PathBuf> {
|
pub fn get_app_config_dir_override() -> Option<PathBuf> {
|
||||||
let app = get_app_handle()?;
|
override_cache().read().ok()?.clone()
|
||||||
get_app_config_dir_from_store(&app)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 Tauri Store 读取 app_config_dir 覆盖配置(公开函数)
|
fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||||
pub fn get_app_config_dir_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
let store = match app.store_builder("app_paths.json").build() {
|
||||||
let store = app.store_builder("app_paths.json").build();
|
Ok(store) => store,
|
||||||
|
Err(e) => {
|
||||||
if let Err(e) = &store {
|
log::warn!("无法创建 Store: {}", e);
|
||||||
log::warn!("无法创建 Store: {}", e);
|
return None;
|
||||||
return None;
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
let store = store.unwrap();
|
|
||||||
|
|
||||||
match store.get(STORE_KEY_APP_CONFIG_DIR) {
|
match store.get(STORE_KEY_APP_CONFIG_DIR) {
|
||||||
Some(Value::String(path_str)) => {
|
Some(Value::String(path_str)) => {
|
||||||
@@ -50,7 +44,6 @@ pub fn get_app_config_dir_from_store(app: &tauri::AppHandle) -> Option<PathBuf>
|
|||||||
|
|
||||||
let path = resolve_path(path_str);
|
let path = resolve_path(path_str);
|
||||||
|
|
||||||
// 验证路径是否存在
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Store 中配置的 app_config_dir 不存在: {:?}\n\
|
"Store 中配置的 app_config_dir 不存在: {:?}\n\
|
||||||
@@ -64,22 +57,32 @@ pub fn get_app_config_dir_from_store(app: &tauri::AppHandle) -> Option<PathBuf>
|
|||||||
Some(path)
|
Some(path)
|
||||||
}
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
log::warn!("Store 中的 {} 类型不正确,应为字符串", STORE_KEY_APP_CONFIG_DIR);
|
log::warn!(
|
||||||
|
"Store 中的 {} 类型不正确,应为字符串",
|
||||||
|
STORE_KEY_APP_CONFIG_DIR
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 Store 刷新 app_config_dir 覆盖值并更新缓存
|
||||||
|
pub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||||
|
let value = read_override_from_store(app);
|
||||||
|
update_cached_override(value.clone());
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
/// 写入 app_config_dir 到 Tauri Store
|
/// 写入 app_config_dir 到 Tauri Store
|
||||||
pub fn set_app_config_dir_to_store(
|
pub fn set_app_config_dir_to_store(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
path: Option<&str>,
|
path: Option<&str>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), AppError> {
|
||||||
let store = app
|
let store = app
|
||||||
.store_builder("app_paths.json")
|
.store_builder("app_paths.json")
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("创建 Store 失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("创建 Store 失败: {}", e)))?;
|
||||||
|
|
||||||
match path {
|
match path {
|
||||||
Some(p) => {
|
Some(p) => {
|
||||||
@@ -88,20 +91,21 @@ pub fn set_app_config_dir_to_store(
|
|||||||
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
|
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
|
||||||
log::info!("已将 app_config_dir 写入 Store: {}", trimmed);
|
log::info!("已将 app_config_dir 写入 Store: {}", trimmed);
|
||||||
} else {
|
} else {
|
||||||
// 空字符串 = 删除配置
|
|
||||||
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
||||||
log::info!("已从 Store 中删除 app_config_dir 配置");
|
log::info!("已从 Store 中删除 app_config_dir 配置");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// None = 删除配置
|
|
||||||
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
||||||
log::info!("已从 Store 中删除 app_config_dir 配置");
|
log::info!("已从 Store 中删除 app_config_dir 配置");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
store.save().map_err(|e| format!("保存 Store 失败: {}", e))?;
|
store
|
||||||
|
.save()
|
||||||
|
.map_err(|e| AppError::Message(format!("保存 Store 失败: {}", e)))?;
|
||||||
|
|
||||||
|
refresh_app_config_dir_override(app);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,13 +129,11 @@ fn resolve_path(raw: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 从旧的 settings.json 迁移 app_config_dir 到 Store
|
/// 从旧的 settings.json 迁移 app_config_dir 到 Store
|
||||||
pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), String> {
|
pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> {
|
||||||
// app_config_dir 已从 settings.json 移除,此函数保留但不再执行迁移
|
// app_config_dir 已从 settings.json 移除,此函数保留但不再执行迁移
|
||||||
// 如果用户在旧版本设置过 app_config_dir,需要在 Store 中手动配置
|
// 如果用户在旧版本设置过 app_config_dir,需要在 Store 中手动配置
|
||||||
log::info!("app_config_dir 迁移功能已移除,请在设置中重新配置");
|
log::info!("app_config_dir 迁移功能已移除,请在设置中重新配置");
|
||||||
|
|
||||||
// 确保 Store 初始化正常
|
let _ = refresh_app_config_dir_override(app);
|
||||||
let _ = get_app_config_dir_from_store(app);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use std::env;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::config::atomic_write;
|
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -15,34 +16,70 @@ pub struct McpStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn user_config_path() -> PathBuf {
|
fn user_config_path() -> PathBuf {
|
||||||
// 用户级 MCP 配置文件:~/.claude.json
|
ensure_mcp_override_migrated();
|
||||||
dirs::home_dir()
|
get_claude_mcp_path()
|
||||||
.expect("无法获取用户主目录")
|
|
||||||
.join(".claude.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_json_value(path: &Path) -> Result<Value, String> {
|
fn ensure_mcp_override_migrated() {
|
||||||
|
if crate::settings::get_claude_override_dir().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_path = get_claude_mcp_path();
|
||||||
|
if new_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let legacy_path = get_default_claude_mcp_path();
|
||||||
|
if !legacy_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = new_path.parent() {
|
||||||
|
if let Err(err) = fs::create_dir_all(parent) {
|
||||||
|
log::warn!("创建 MCP 目录失败: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::copy(&legacy_path, &new_path) {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!(
|
||||||
|
"已根据覆盖目录复制 MCP 配置: {} -> {}",
|
||||||
|
legacy_path.display(),
|
||||||
|
new_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"复制 MCP 配置失败: {} -> {}: {}",
|
||||||
|
legacy_path.display(),
|
||||||
|
new_path.display(),
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(serde_json::json!({}));
|
return Ok(serde_json::json!({}));
|
||||||
}
|
}
|
||||||
let content =
|
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
||||||
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
|
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
|
||||||
let value: Value = serde_json::from_str(&content)
|
|
||||||
.map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))?;
|
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_json_value(path: &Path, value: &Value) -> Result<(), String> {
|
fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
|
|
||||||
}
|
}
|
||||||
let json =
|
let json =
|
||||||
serde_json::to_string_pretty(value).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
|
serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
atomic_write(path, json.as_bytes())
|
atomic_write(path, json.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_mcp_status() -> Result<McpStatus, String> {
|
pub fn get_mcp_status() -> Result<McpStatus, AppError> {
|
||||||
let path = user_config_path();
|
let path = user_config_path();
|
||||||
let (exists, count) = if path.exists() {
|
let (exists, count) = if path.exists() {
|
||||||
let v = read_json_value(&path)?;
|
let v = read_json_value(&path)?;
|
||||||
@@ -59,35 +96,41 @@ pub fn get_mcp_status() -> Result<McpStatus, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_mcp_json() -> Result<Option<String>, String> {
|
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||||
let path = user_config_path();
|
let path = user_config_path();
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let content = fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {}", e))?;
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
Ok(Some(content))
|
Ok(Some(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, String> {
|
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err("MCP 服务器 ID 不能为空".into());
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
}
|
}
|
||||||
// 基础字段校验(尽量宽松)
|
// 基础字段校验(尽量宽松)
|
||||||
if !spec.is_object() {
|
if !spec.is_object() {
|
||||||
return Err("MCP 服务器定义必须为 JSON 对象".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器定义必须为 JSON 对象".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||||
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
|
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
|
||||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||||
if !(is_stdio || is_http) {
|
if !(is_stdio || is_http) {
|
||||||
return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// stdio 类型必须有 command
|
// stdio 类型必须有 command
|
||||||
if is_stdio {
|
if is_stdio {
|
||||||
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
if cmd.is_empty() {
|
if cmd.is_empty() {
|
||||||
return Err("stdio 类型的 MCP 服务器缺少 command 字段".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +138,9 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, String> {
|
|||||||
if is_http {
|
if is_http {
|
||||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
return Err("http 类型的 MCP 服务器缺少 url 字段".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +155,7 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, String> {
|
|||||||
{
|
{
|
||||||
let obj = root
|
let obj = root
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.ok_or_else(|| "mcp.json 根必须是对象".to_string())?;
|
.ok_or_else(|| AppError::Config("mcp.json 根必须是对象".into()))?;
|
||||||
if !obj.contains_key("mcpServers") {
|
if !obj.contains_key("mcpServers") {
|
||||||
obj.insert("mcpServers".into(), serde_json::json!({}));
|
obj.insert("mcpServers".into(), serde_json::json!({}));
|
||||||
}
|
}
|
||||||
@@ -129,9 +174,9 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, String> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_mcp_server(id: &str) -> Result<bool, String> {
|
pub fn delete_mcp_server(id: &str) -> Result<bool, AppError> {
|
||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err("MCP 服务器 ID 不能为空".into());
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
}
|
}
|
||||||
let path = user_config_path();
|
let path = user_config_path();
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -149,7 +194,7 @@ pub fn delete_mcp_server(id: &str) -> Result<bool, String> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_command_in_path(cmd: &str) -> Result<bool, String> {
|
pub fn validate_command_in_path(cmd: &str) -> Result<bool, AppError> {
|
||||||
if cmd.trim().is_empty() {
|
if cmd.trim().is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -190,7 +235,7 @@ pub fn validate_command_in_path(cmd: &str) -> Result<bool, String> {
|
|||||||
/// 仅覆盖 mcpServers,其他字段保持不变
|
/// 仅覆盖 mcpServers,其他字段保持不变
|
||||||
pub fn set_mcp_servers_map(
|
pub fn set_mcp_servers_map(
|
||||||
servers: &std::collections::HashMap<String, Value>,
|
servers: &std::collections::HashMap<String, Value>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), AppError> {
|
||||||
let path = user_config_path();
|
let path = user_config_path();
|
||||||
let mut root = if path.exists() {
|
let mut root = if path.exists() {
|
||||||
read_json_value(&path)?
|
read_json_value(&path)?
|
||||||
@@ -204,14 +249,16 @@ pub fn set_mcp_servers_map(
|
|||||||
let mut obj = if let Some(map) = spec.as_object() {
|
let mut obj = if let Some(map) = spec.as_object() {
|
||||||
map.clone()
|
map.clone()
|
||||||
} else {
|
} else {
|
||||||
return Err(format!("MCP 服务器 '{}' 不是对象", id));
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"MCP 服务器 '{}' 不是对象",
|
||||||
|
id
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(server_val) = obj.remove("server") {
|
if let Some(server_val) = obj.remove("server") {
|
||||||
let server_obj = server_val
|
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
||||||
.as_object()
|
AppError::McpValidation(format!("MCP 服务器 '{}' server 字段不是对象", id))
|
||||||
.cloned()
|
})?;
|
||||||
.ok_or_else(|| format!("MCP 服务器 '{}' server 字段不是对象", id))?;
|
|
||||||
obj = server_obj;
|
obj = server_obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +277,7 @@ pub fn set_mcp_servers_map(
|
|||||||
{
|
{
|
||||||
let obj = root
|
let obj = root
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.ok_or_else(|| "~/.claude.json 根必须是对象".to_string())?;
|
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
|
||||||
obj.insert("mcpServers".into(), Value::Object(out));
|
obj.insert("mcpServers".into(), Value::Object(out));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
const CLAUDE_DIR: &str = ".claude";
|
const CLAUDE_DIR: &str = ".claude";
|
||||||
const CLAUDE_CONFIG_FILE: &str = "config.json";
|
const CLAUDE_CONFIG_FILE: &str = "config.json";
|
||||||
|
|
||||||
fn claude_dir() -> Result<PathBuf, String> {
|
fn claude_dir() -> Result<PathBuf, AppError> {
|
||||||
// 优先使用设置中的覆盖目录
|
// 优先使用设置中的覆盖目录
|
||||||
if let Some(dir) = crate::settings::get_claude_override_dir() {
|
if let Some(dir) = crate::settings::get_claude_override_dir() {
|
||||||
return Ok(dir);
|
return Ok(dir);
|
||||||
}
|
}
|
||||||
let home = dirs::home_dir().ok_or_else(|| "无法获取用户主目录".to_string())?;
|
let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;
|
||||||
Ok(home.join(CLAUDE_DIR))
|
Ok(home.join(CLAUDE_DIR))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn claude_config_path() -> Result<PathBuf, String> {
|
pub fn claude_config_path() -> Result<PathBuf, AppError> {
|
||||||
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
|
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_claude_dir_exists() -> Result<PathBuf, String> {
|
pub fn ensure_claude_dir_exists() -> Result<PathBuf, AppError> {
|
||||||
let dir = claude_dir()?;
|
let dir = claude_dir()?;
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
fs::create_dir_all(&dir).map_err(|e| format!("创建 Claude 配置目录失败: {}", e))?;
|
fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?;
|
||||||
}
|
}
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_claude_config() -> Result<Option<String>, String> {
|
pub fn read_claude_config() -> Result<Option<String>, AppError> {
|
||||||
let path = claude_config_path()?;
|
let path = claude_config_path()?;
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let content =
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
fs::read_to_string(&path).map_err(|e| format!("读取 Claude 配置失败: {}", e))?;
|
|
||||||
Ok(Some(content))
|
Ok(Some(content))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -47,7 +48,7 @@ fn is_managed_config(content: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_claude_config() -> Result<bool, String> {
|
pub fn write_claude_config() -> Result<bool, AppError> {
|
||||||
// 增量写入:仅设置 primaryApiKey = "any",保留其它字段
|
// 增量写入:仅设置 primaryApiKey = "any",保留其它字段
|
||||||
let path = claude_config_path()?;
|
let path = claude_config_path()?;
|
||||||
ensure_claude_dir_exists()?;
|
ensure_claude_dir_exists()?;
|
||||||
@@ -78,16 +79,15 @@ pub fn write_claude_config() -> Result<bool, String> {
|
|||||||
|
|
||||||
if changed || !path.exists() {
|
if changed || !path.exists() {
|
||||||
let serialized = serde_json::to_string_pretty(&obj)
|
let serialized = serde_json::to_string_pretty(&obj)
|
||||||
.map_err(|e| format!("序列化 Claude 配置失败: {}", e))?;
|
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
fs::write(&path, format!("{}\n", serialized))
|
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
|
||||||
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
} else {
|
} else {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_claude_config() -> Result<bool, String> {
|
pub fn clear_claude_config() -> Result<bool, AppError> {
|
||||||
let path = claude_config_path()?;
|
let path = claude_config_path()?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -112,19 +112,18 @@ pub fn clear_claude_config() -> Result<bool, String> {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let serialized = serde_json::to_string_pretty(&value)
|
let serialized =
|
||||||
.map_err(|e| format!("序列化 Claude 配置失败: {}", e))?;
|
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
fs::write(&path, format!("{}\n", serialized))
|
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
|
||||||
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn claude_config_status() -> Result<(bool, PathBuf), String> {
|
pub fn claude_config_status() -> Result<(bool, PathBuf), AppError> {
|
||||||
let path = claude_config_path()?;
|
let path = claude_config_path()?;
|
||||||
Ok((path.exists(), path))
|
Ok((path.exists(), path))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_claude_config_applied() -> Result<bool, String> {
|
pub fn is_claude_config_applied() -> Result<bool, AppError> {
|
||||||
match read_claude_config()? {
|
match read_claude_config()? {
|
||||||
Some(content) => Ok(is_managed_config(&content)),
|
Some(content) => Ok(is_managed_config(&content)),
|
||||||
None => Ok(false),
|
None => Ok(false),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::path::PathBuf;
|
|||||||
use crate::config::{
|
use crate::config::{
|
||||||
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
|
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
|
||||||
};
|
};
|
||||||
|
use crate::error::AppError;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -43,7 +44,10 @@ pub fn get_codex_provider_paths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 删除 Codex 供应商配置文件
|
/// 删除 Codex 供应商配置文件
|
||||||
pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
pub fn delete_codex_provider_config(
|
||||||
|
provider_id: &str,
|
||||||
|
provider_name: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
||||||
|
|
||||||
delete_file(&auth_path).ok();
|
delete_file(&auth_path).ok();
|
||||||
@@ -55,32 +59,28 @@ pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> R
|
|||||||
//(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警)
|
//(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警)
|
||||||
|
|
||||||
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
|
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
|
||||||
pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> Result<(), String> {
|
pub fn write_codex_live_atomic(
|
||||||
|
auth: &Value,
|
||||||
|
config_text_opt: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let auth_path = get_codex_auth_path();
|
let auth_path = get_codex_auth_path();
|
||||||
let config_path = get_codex_config_path();
|
let config_path = get_codex_config_path();
|
||||||
|
|
||||||
if let Some(parent) = auth_path.parent() {
|
if let Some(parent) = auth_path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
.map_err(|e| format!("创建 Codex 目录失败: {}: {}", parent.display(), e))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取旧内容用于回滚
|
// 读取旧内容用于回滚
|
||||||
let old_auth = if auth_path.exists() {
|
let old_auth = if auth_path.exists() {
|
||||||
Some(
|
Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
|
||||||
fs::read(&auth_path)
|
} else {
|
||||||
.map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?,
|
None
|
||||||
)
|
};
|
||||||
|
let _old_config = if config_path.exists() {
|
||||||
|
Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let _old_config =
|
|
||||||
if config_path.exists() {
|
|
||||||
Some(fs::read(&config_path).map_err(|e| {
|
|
||||||
format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e)
|
|
||||||
})?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// 准备写入内容
|
// 准备写入内容
|
||||||
let cfg_text = match config_text_opt {
|
let cfg_text = match config_text_opt {
|
||||||
@@ -88,13 +88,7 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
|
|||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
if !cfg_text.trim().is_empty() {
|
if !cfg_text.trim().is_empty() {
|
||||||
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| {
|
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
|
||||||
format!(
|
|
||||||
"config.toml 语法错误: {} (路径: {})",
|
|
||||||
e,
|
|
||||||
config_path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一步:写 auth.json
|
// 第一步:写 auth.json
|
||||||
@@ -115,43 +109,43 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
|
/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
|
||||||
pub fn read_codex_config_text() -> Result<String, String> {
|
pub fn read_codex_config_text() -> Result<String, AppError> {
|
||||||
let path = get_codex_config_path();
|
let path = get_codex_config_path();
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
std::fs::read_to_string(&path).map_err(|e| format!("读取 config.toml 失败: {}", e))
|
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
|
||||||
} else {
|
} else {
|
||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串
|
/// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串
|
||||||
pub fn read_config_text_from_path(path: &Path) -> Result<String, String> {
|
pub fn read_config_text_from_path(path: &Path) -> Result<String, AppError> {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
std::fs::read_to_string(path).map_err(|e| format!("读取 {} 失败: {}", path.display(), e))
|
std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))
|
||||||
} else {
|
} else {
|
||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 对非空的 TOML 文本进行语法校验
|
/// 对非空的 TOML 文本进行语法校验
|
||||||
pub fn validate_config_toml(text: &str) -> Result<(), String> {
|
pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
toml::from_str::<toml::Table>(text)
|
toml::from_str::<toml::Table>(text)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|e| format!("config.toml 语法错误: {}", e))
|
.map_err(|e| AppError::toml(Path::new("config.toml"), e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
|
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
|
||||||
pub fn read_and_validate_codex_config_text() -> Result<String, String> {
|
pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
||||||
let s = read_codex_config_text()?;
|
let s = read_codex_config_text()?;
|
||||||
validate_config_toml(&s)?;
|
validate_config_toml(&s)?;
|
||||||
Ok(s)
|
Ok(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从指定路径读取并校验 config.toml,返回文本(可能为空)
|
/// 从指定路径读取并校验 config.toml,返回文本(可能为空)
|
||||||
pub fn read_and_validate_config_from_path(path: &Path) -> Result<String, String> {
|
pub fn read_and_validate_config_from_path(path: &Path) -> Result<String, AppError> {
|
||||||
let s = read_config_text_from_path(path)?;
|
let s = read_config_text_from_path(path)?;
|
||||||
validate_config_toml(&s)?;
|
validate_config_toml(&s)?;
|
||||||
Ok(s)
|
Ok(s)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
126
src-tauri/src/commands/config.rs
Normal file
126
src-tauri/src/commands/config.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::codex_config;
|
||||||
|
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||||
|
|
||||||
|
/// 获取 Claude Code 配置状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||||
|
Ok(config::get_claude_config_status())
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||||
|
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
|
AppType::Claude => Ok(config::get_claude_config_status()),
|
||||||
|
AppType::Codex => {
|
||||||
|
let auth_path = codex_config::get_codex_auth_path();
|
||||||
|
let exists = auth_path.exists();
|
||||||
|
let path = codex_config::get_codex_config_dir()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(ConfigStatus { exists, path })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Claude Code 配置文件路径
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||||
|
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前生效的配置目录
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||||
|
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(dir.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开配置文件夹
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
|
||||||
|
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !config_dir.exists() {
|
||||||
|
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle
|
||||||
|
.opener()
|
||||||
|
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||||
|
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 弹出系统目录选择器并返回用户选择的路径
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pick_directory(
|
||||||
|
app: AppHandle,
|
||||||
|
default_path: Option<String>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let initial = default_path
|
||||||
|
.map(|p| p.trim().to_string())
|
||||||
|
.filter(|p| !p.is_empty());
|
||||||
|
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let mut builder = app.dialog().file();
|
||||||
|
if let Some(path) = initial {
|
||||||
|
builder = builder.set_directory(path);
|
||||||
|
}
|
||||||
|
builder.blocking_pick_folder()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Some(file_path) => {
|
||||||
|
let resolved = file_path
|
||||||
|
.simplified()
|
||||||
|
.into_path()
|
||||||
|
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
|
||||||
|
Ok(Some(resolved.to_string_lossy().to_string()))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取应用配置文件路径
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_app_config_path() -> Result<String, String> {
|
||||||
|
let config_path = config::get_app_config_path();
|
||||||
|
Ok(config_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开应用配置文件夹
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
||||||
|
let config_dir = config::get_app_config_dir();
|
||||||
|
|
||||||
|
if !config_dir.exists() {
|
||||||
|
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle
|
||||||
|
.opener()
|
||||||
|
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||||
|
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
104
src-tauri/src/commands/import_export.rs
Normal file
104
src-tauri/src/commands/import_export.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::ConfigService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// 导出配置文件
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let target_path = PathBuf::from(&file_path);
|
||||||
|
ConfigService::export_config_to_path(&target_path)?;
|
||||||
|
Ok::<_, AppError>(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Configuration exported successfully",
|
||||||
|
"filePath": file_path
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("导出配置失败: {}", e))?
|
||||||
|
.map_err(|e: AppError| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从文件导入配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_config_from_file(
|
||||||
|
file_path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let path_buf = PathBuf::from(&file_path);
|
||||||
|
ConfigService::load_config_for_import(&path_buf)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("导入配置失败: {}", e))?
|
||||||
|
.map_err(|e: AppError| e.to_string())?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = state
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|e| AppError::from(e).to_string())?;
|
||||||
|
*guard = new_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Configuration imported successfully",
|
||||||
|
"backupId": backup_id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步当前供应商配置到对应的 live 文件
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
|
||||||
|
{
|
||||||
|
let mut config_state = state
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|e| AppError::from(e).to_string())?;
|
||||||
|
ConfigService::sync_current_providers_to_live(&mut config_state)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Live configuration synchronized"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存文件对话框
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_file_dialog<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
default_name: String,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let dialog = app.dialog();
|
||||||
|
let result = dialog
|
||||||
|
.file()
|
||||||
|
.add_filter("JSON", &["json"])
|
||||||
|
.set_file_name(&default_name)
|
||||||
|
.blocking_save_file();
|
||||||
|
|
||||||
|
Ok(result.map(|p| p.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开文件对话框
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_file_dialog<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let dialog = app.dialog();
|
||||||
|
let result = dialog
|
||||||
|
.file()
|
||||||
|
.add_filter("JSON", &["json"])
|
||||||
|
.blocking_pick_file();
|
||||||
|
|
||||||
|
Ok(result.map(|p| p.to_string()))
|
||||||
|
}
|
||||||
131
src-tauri/src/commands/mcp.rs
Normal file
131
src-tauri/src/commands/mcp.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::claude_mcp;
|
||||||
|
use crate::services::McpService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// 获取 Claude MCP 状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_mcp_status() -> Result<claude_mcp::McpStatus, String> {
|
||||||
|
claude_mcp::get_mcp_status().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 mcp.json 文本内容
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_claude_mcp_config() -> Result<Option<String>, String> {
|
||||||
|
claude_mcp::read_mcp_json().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增或更新一个 MCP 服务器条目
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {
|
||||||
|
claude_mcp::upsert_mcp_server(&id, spec).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除一个 MCP 服务器条目
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_claude_mcp_server(id: String) -> Result<bool, String> {
|
||||||
|
claude_mcp::delete_mcp_server(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验命令是否在 PATH 中可用(不执行)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn validate_mcp_command(cmd: String) -> Result<bool, String> {
|
||||||
|
claude_mcp::validate_command_in_path(&cmd).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct McpConfigResponse {
|
||||||
|
pub config_path: String,
|
||||||
|
pub servers: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 MCP 配置(来自 ~/.cc-switch/config.json)
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_mcp_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
) -> Result<McpConfigResponse, String> {
|
||||||
|
let config_path = crate::config::get_app_config_path()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
let servers = McpService::get_servers(&state, app_ty).map_err(|e| e.to_string())?;
|
||||||
|
Ok(McpConfigResponse {
|
||||||
|
config_path,
|
||||||
|
servers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 config.json 中新增或更新一个 MCP 服务器定义
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upsert_mcp_server_in_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
spec: serde_json::Value,
|
||||||
|
sync_other_side: Option<bool>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
McpService::upsert_server(&state, app_ty, &id, spec, sync_other_side.unwrap_or(false))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 config.json 中删除一个 MCP 服务器定义
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_mcp_server_in_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
McpService::delete_server(&state, app_ty, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置启用状态并同步到客户端配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_mcp_enabled(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
McpService::sync_enabled(&state, AppType::Claude)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
McpService::sync_enabled(&state, AppType::Codex)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 ~/.claude.json 导入 MCP 定义到 config.json
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
|
McpService::import_from_claude(&state).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
|
McpService::import_from_codex(&state).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
45
src-tauri/src/commands/misc.rs
Normal file
45
src-tauri/src/commands/misc.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
/// 打开外部链接
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||||||
|
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
format!("https://{}", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
app.opener()
|
||||||
|
.open_url(&url, None::<String>)
|
||||||
|
.map_err(|e| format!("打开链接失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查更新
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
||||||
|
handle
|
||||||
|
.opener()
|
||||||
|
.open_url(
|
||||||
|
"https://github.com/farion1231/cc-switch/releases/latest",
|
||||||
|
None::<String>,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("打开更新页面失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否为便携版(绿色版)运行
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_portable_mode() -> Result<bool, String> {
|
||||||
|
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
|
||||||
|
if let Some(dir) = exe_path.parent() {
|
||||||
|
Ok(dir.join("portable.ini").is_file())
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src-tauri/src/commands/mod.rs
Normal file
17
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod import_export;
|
||||||
|
mod mcp;
|
||||||
|
mod misc;
|
||||||
|
mod plugin;
|
||||||
|
mod provider;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
|
pub use config::*;
|
||||||
|
pub use import_export::*;
|
||||||
|
pub use mcp::*;
|
||||||
|
pub use misc::*;
|
||||||
|
pub use plugin::*;
|
||||||
|
pub use provider::*;
|
||||||
|
pub use settings::*;
|
||||||
36
src-tauri/src/commands/plugin.rs
Normal file
36
src-tauri/src/commands/plugin.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use crate::config::ConfigStatus;
|
||||||
|
|
||||||
|
/// Claude 插件:获取 ~/.claude/config.json 状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {
|
||||||
|
crate::claude_plugin::claude_config_status()
|
||||||
|
.map(|(exists, path)| ConfigStatus {
|
||||||
|
exists,
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude 插件:读取配置内容(若不存在返回 Ok(None))
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_claude_plugin_config() -> Result<Option<String>, String> {
|
||||||
|
crate::claude_plugin::read_claude_config().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude 插件:写入/清除固定配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {
|
||||||
|
if official {
|
||||||
|
crate::claude_plugin::clear_claude_config().map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
crate::claude_plugin::write_claude_config().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude 插件:检测是否已写入目标配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
||||||
|
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
211
src-tauri/src/commands/provider.rs
Normal file
211
src-tauri/src/commands/provider.rs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
fn missing_param(param: &str) -> String {
|
||||||
|
format!("缺少 {} 参数 (Missing {} parameter)", param, param)
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// 获取所有供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_providers(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
) -> Result<HashMap<String, Provider>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前供应商ID
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::delete(state.inner(), app_type, &id)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换供应商
|
||||||
|
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
ProviderService::switch(state, app_type, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
|
pub fn switch_provider_test_hook(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: AppType,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
switch_provider_internal(state, app_type, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn switch_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
switch_provider_internal(&state, app_type, &id)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
|
||||||
|
ProviderService::import_default_config(state, app_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
|
pub fn import_default_config_test_hook(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: AppType,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
import_default_config_internal(state, app_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导入当前配置为默认供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
import_default_config_internal(&state, app_type)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询供应商用量
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn query_provider_usage(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
provider_id: Option<String>,
|
||||||
|
app: String,
|
||||||
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
|
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::query_usage(state.inner(), app_type, &provider_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取当前生效的配置内容
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试第三方/自定义供应商端点的网络延迟
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_api_endpoints(
|
||||||
|
urls: Vec<String>,
|
||||||
|
timeout_secs: Option<u64>,
|
||||||
|
) -> Result<Vec<EndpointLatency>, String> {
|
||||||
|
SpeedtestService::test_endpoints(urls, timeout_secs)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取自定义端点列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_custom_endpoints(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
provider_id: Option<String>,
|
||||||
|
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||||
|
ProviderService::get_custom_endpoints(state.inner(), app_type, &provider_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加自定义端点
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_custom_endpoint(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
provider_id: Option<String>,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||||
|
ProviderService::add_custom_endpoint(state.inner(), app_type, &provider_id, url)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除自定义端点
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_custom_endpoint(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
provider_id: Option<String>,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||||
|
ProviderService::remove_custom_endpoint(state.inner(), app_type, &provider_id, url)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新端点最后使用时间
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_endpoint_last_used(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
provider_id: Option<String>,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||||
|
ProviderService::update_endpoint_last_used(state.inner(), app_type, &provider_id, url)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新多个供应商的排序
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_providers_sort_order(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
updates: Vec<ProviderSortUpdate>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
39
src-tauri/src/commands/settings.rs
Normal file
39
src-tauri/src/commands/settings.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
/// 获取设置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
||||||
|
Ok(crate::settings::get_settings())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存设置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
||||||
|
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重启应用程序(当 app_config_dir 变更后使用)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
||||||
|
app.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 app_config_dir 覆盖配置 (从 Store)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
|
Ok(crate::app_store::refresh_app_config_dir_override(&app)
|
||||||
|
.map(|p| p.to_string_lossy().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 app_config_dir 覆盖配置 (到 Store)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_app_config_dir_override(
|
||||||
|
app: AppHandle,
|
||||||
|
path: Option<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
// unused import removed
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// 获取 Claude Code 配置目录路径
|
/// 获取 Claude Code 配置目录路径
|
||||||
pub fn get_claude_config_dir() -> PathBuf {
|
pub fn get_claude_config_dir() -> PathBuf {
|
||||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||||||
@@ -15,6 +16,36 @@ pub fn get_claude_config_dir() -> PathBuf {
|
|||||||
.join(".claude")
|
.join(".claude")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
|
||||||
|
pub fn get_default_claude_mcp_path() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join(".claude.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
||||||
|
let file_name = dir
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().to_string())?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if file_name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
||||||
|
Some(parent.join(format!("{}.json", file_name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
||||||
|
pub fn get_claude_mcp_path() -> PathBuf {
|
||||||
|
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
|
||||||
|
if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get_default_claude_mcp_path()
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 Claude Code 主配置文件路径
|
/// 获取 Claude Code 主配置文件路径
|
||||||
pub fn get_claude_settings_path() -> PathBuf {
|
pub fn get_claude_settings_path() -> PathBuf {
|
||||||
let dir = get_claude_config_dir();
|
let dir = get_claude_config_dir();
|
||||||
@@ -76,14 +107,14 @@ fn ensure_unique_path(dest: PathBuf) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 将现有文件归档到 `~/.cc-switch/archive/<ts>/<category>/` 下,返回归档路径
|
/// 将现有文件归档到 `~/.cc-switch/archive/<ts>/<category>/` 下,返回归档路径
|
||||||
pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBuf>, String> {
|
pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBuf>, AppError> {
|
||||||
if !src.exists() {
|
if !src.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let mut dest_dir = get_archive_root();
|
let mut dest_dir = get_archive_root();
|
||||||
dest_dir.push(ts.to_string());
|
dest_dir.push(ts.to_string());
|
||||||
dest_dir.push(category);
|
dest_dir.push(category);
|
||||||
fs::create_dir_all(&dest_dir).map_err(|e| format!("创建归档目录失败: {}", e))?;
|
fs::create_dir_all(&dest_dir).map_err(|e| AppError::io(&dest_dir, e))?;
|
||||||
|
|
||||||
let file_name = src
|
let file_name = src
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -117,52 +148,50 @@ pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
/// 读取 JSON 配置文件
|
||||||
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, String> {
|
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppError> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(format!("文件不存在: {}", path.display()));
|
return Err(AppError::Config(format!("文件不存在: {}", path.display())));
|
||||||
}
|
}
|
||||||
|
|
||||||
let content =
|
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
||||||
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
|
|
||||||
|
|
||||||
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))
|
serde_json::from_str(&content).map_err(|e| AppError::json(path, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写入 JSON 配置文件
|
/// 写入 JSON 配置文件
|
||||||
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
|
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let json =
|
let json =
|
||||||
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
|
serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
|
||||||
atomic_write(path, json.as_bytes())
|
atomic_write(path, json.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 原子写入文本文件(用于 TOML/纯文本)
|
/// 原子写入文本文件(用于 TOML/纯文本)
|
||||||
pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
|
pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
|
|
||||||
}
|
}
|
||||||
atomic_write(path, data.as_bytes())
|
atomic_write(path, data.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
|
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
|
||||||
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?;
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
|
||||||
let mut tmp = parent.to_path_buf();
|
let mut tmp = parent.to_path_buf();
|
||||||
let file_name = path
|
let file_name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.ok_or_else(|| "无效的文件名".to_string())?
|
.ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
let ts = std::time::SystemTime::now()
|
let ts = std::time::SystemTime::now()
|
||||||
@@ -172,12 +201,9 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
|||||||
tmp.push(format!("{}.tmp.{}", file_name, ts));
|
tmp.push(format!("{}.tmp.{}", file_name, ts));
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut f = fs::File::create(&tmp)
|
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
|
||||||
.map_err(|e| format!("创建临时文件失败: {}: {}", tmp.display(), e))?;
|
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
|
||||||
f.write_all(data)
|
f.flush().map_err(|e| AppError::io(&tmp, e))?;
|
||||||
.map_err(|e| format!("写入临时文件失败: {}: {}", tmp.display(), e))?;
|
|
||||||
f.flush()
|
|
||||||
.map_err(|e| format!("刷新临时文件失败: {}: {}", tmp.display(), e))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -195,40 +221,70 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
|||||||
if path.exists() {
|
if path.exists() {
|
||||||
let _ = fs::remove_file(path);
|
let _ = fs::remove_file(path);
|
||||||
}
|
}
|
||||||
fs::rename(&tmp, path).map_err(|e| {
|
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
|
||||||
format!(
|
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||||
"原子替换失败: {} -> {}: {}",
|
source: e,
|
||||||
tmp.display(),
|
|
||||||
path.display(),
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
fs::rename(&tmp, path).map_err(|e| {
|
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
|
||||||
format!(
|
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||||
"原子替换失败: {} -> {}: {}",
|
source: e,
|
||||||
tmp.display(),
|
|
||||||
path.display(),
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_mcp_path_from_override_preserves_folder_name() {
|
||||||
|
let override_dir = PathBuf::from("/tmp/profile/.claude");
|
||||||
|
let derived = derive_mcp_path_from_override(&override_dir)
|
||||||
|
.expect("should derive path for nested dir");
|
||||||
|
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_mcp_path_from_override_handles_non_hidden_folder() {
|
||||||
|
let override_dir = PathBuf::from("/data/claude-config");
|
||||||
|
let derived = derive_mcp_path_from_override(&override_dir)
|
||||||
|
.expect("should derive path for standard dir");
|
||||||
|
assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
|
||||||
|
let override_dir = PathBuf::from("claude");
|
||||||
|
let derived = derive_mcp_path_from_override(&override_dir)
|
||||||
|
.expect("should derive path for single segment");
|
||||||
|
assert_eq!(derived, PathBuf::from("claude.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_mcp_path_from_root_like_dir_returns_none() {
|
||||||
|
let override_dir = PathBuf::from("/");
|
||||||
|
assert!(derive_mcp_path_from_override(&override_dir).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 复制文件
|
/// 复制文件
|
||||||
pub fn copy_file(from: &Path, to: &Path) -> Result<(), String> {
|
pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
|
||||||
fs::copy(from, to).map_err(|e| format!("复制文件失败: {}", e))?;
|
fs::copy(from, to).map_err(|e| AppError::IoContext {
|
||||||
|
context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除文件
|
/// 删除文件
|
||||||
pub fn delete_file(path: &Path) -> Result<(), String> {
|
pub fn delete_file(path: &Path) -> Result<(), AppError> {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
fs::remove_file(path).map_err(|e| format!("删除文件失败: {}", e))?;
|
fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -249,4 +305,4 @@ pub fn get_claude_config_status() -> ConfigStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//(移除未使用的备份/导入函数,避免 dead_code 告警)
|
//(移除未使用的备份/导入函数,避免 dead_code 告警)
|
||||||
|
|||||||
98
src-tauri/src/error.rs
Normal file
98
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::sync::PoisonError;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("配置错误: {0}")]
|
||||||
|
Config(String),
|
||||||
|
#[error("无效输入: {0}")]
|
||||||
|
InvalidInput(String),
|
||||||
|
#[error("IO 错误: {path}: {source}")]
|
||||||
|
Io {
|
||||||
|
path: String,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("{context}: {source}")]
|
||||||
|
IoContext {
|
||||||
|
context: String,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("JSON 解析错误: {path}: {source}")]
|
||||||
|
Json {
|
||||||
|
path: String,
|
||||||
|
#[source]
|
||||||
|
source: serde_json::Error,
|
||||||
|
},
|
||||||
|
#[error("JSON 序列化失败: {source}")]
|
||||||
|
JsonSerialize {
|
||||||
|
#[source]
|
||||||
|
source: serde_json::Error,
|
||||||
|
},
|
||||||
|
#[error("TOML 解析错误: {path}: {source}")]
|
||||||
|
Toml {
|
||||||
|
path: String,
|
||||||
|
#[source]
|
||||||
|
source: toml::de::Error,
|
||||||
|
},
|
||||||
|
#[error("锁获取失败: {0}")]
|
||||||
|
Lock(String),
|
||||||
|
#[error("供应商不存在: {0}")]
|
||||||
|
ProviderNotFound(String),
|
||||||
|
#[error("MCP 校验失败: {0}")]
|
||||||
|
McpValidation(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
Message(String),
|
||||||
|
#[error("{zh} ({en})")]
|
||||||
|
Localized {
|
||||||
|
key: &'static str,
|
||||||
|
zh: String,
|
||||||
|
en: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
pub fn io(path: impl AsRef<Path>, source: std::io::Error) -> Self {
|
||||||
|
Self::Io {
|
||||||
|
path: path.as_ref().display().to_string(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn json(path: impl AsRef<Path>, source: serde_json::Error) -> Self {
|
||||||
|
Self::Json {
|
||||||
|
path: path.as_ref().display().to_string(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toml(path: impl AsRef<Path>, source: toml::de::Error) -> Self {
|
||||||
|
Self::Toml {
|
||||||
|
path: path.as_ref().display().to_string(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> Self {
|
||||||
|
Self::Localized {
|
||||||
|
key,
|
||||||
|
zh: zh.into(),
|
||||||
|
en: en.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> From<PoisonError<T>> for AppError {
|
||||||
|
fn from(err: PoisonError<T>) -> Self {
|
||||||
|
Self::Lock(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppError> for String {
|
||||||
|
fn from(err: AppError) -> Self {
|
||||||
|
err.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
// 默认仅保留最近 10 份备份,避免目录无限膨胀
|
|
||||||
const MAX_BACKUPS: usize = 10;
|
|
||||||
|
|
||||||
/// 创建配置文件备份
|
|
||||||
pub fn create_backup(config_path: &PathBuf) -> Result<String, String> {
|
|
||||||
if !config_path.exists() {
|
|
||||||
return Ok(String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
|
||||||
let backup_id = format!("backup_{}", timestamp);
|
|
||||||
|
|
||||||
let backup_dir = config_path
|
|
||||||
.parent()
|
|
||||||
.ok_or("Invalid config path")?
|
|
||||||
.join("backups");
|
|
||||||
|
|
||||||
// 创建备份目录
|
|
||||||
fs::create_dir_all(&backup_dir)
|
|
||||||
.map_err(|e| format!("Failed to create backup directory: {}", e))?;
|
|
||||||
|
|
||||||
let backup_path = backup_dir.join(format!("{}.json", backup_id));
|
|
||||||
|
|
||||||
// 复制配置文件到备份
|
|
||||||
fs::copy(config_path, backup_path).map_err(|e| format!("Failed to create backup: {}", e))?;
|
|
||||||
|
|
||||||
// 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份)
|
|
||||||
cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;
|
|
||||||
|
|
||||||
Ok(backup_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cleanup_old_backups(backup_dir: &PathBuf, retain: usize) -> Result<(), String> {
|
|
||||||
if retain == 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut entries: Vec<_> = match fs::read_dir(backup_dir) {
|
|
||||||
Ok(iter) => iter
|
|
||||||
.filter_map(|entry| entry.ok())
|
|
||||||
.filter(|entry| {
|
|
||||||
entry
|
|
||||||
.path()
|
|
||||||
.extension()
|
|
||||||
.map(|ext| ext == "json")
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
Err(_) => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if entries.len() <= retain {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let remove_count = entries.len().saturating_sub(retain);
|
|
||||||
|
|
||||||
entries.sort_by(|a, b| {
|
|
||||||
let a_time = a.metadata().and_then(|m| m.modified()).ok();
|
|
||||||
let b_time = b.metadata().and_then(|m| m.modified()).ok();
|
|
||||||
a_time.cmp(&b_time)
|
|
||||||
});
|
|
||||||
|
|
||||||
for entry in entries.into_iter().take(remove_count) {
|
|
||||||
if let Err(err) = fs::remove_file(entry.path()) {
|
|
||||||
log::warn!(
|
|
||||||
"Failed to remove old backup {}: {}",
|
|
||||||
entry.path().display(),
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 导出配置文件
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
|
|
||||||
// 读取当前配置文件
|
|
||||||
let config_path = crate::config::get_app_config_path();
|
|
||||||
let config_content = fs::read_to_string(&config_path)
|
|
||||||
.map_err(|e| format!("Failed to read configuration: {}", e))?;
|
|
||||||
|
|
||||||
// 写入到指定文件
|
|
||||||
fs::write(&file_path, &config_content).map_err(|e| format!("Failed to write file: {}", e))?;
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"success": true,
|
|
||||||
"message": "Configuration exported successfully",
|
|
||||||
"filePath": file_path
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从文件导入配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn import_config_from_file(
|
|
||||||
file_path: String,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<Value, String> {
|
|
||||||
// 读取导入的文件
|
|
||||||
let import_content =
|
|
||||||
fs::read_to_string(&file_path).map_err(|e| format!("Failed to read import file: {}", e))?;
|
|
||||||
|
|
||||||
// 验证并解析为配置对象
|
|
||||||
let new_config: crate::app_config::MultiAppConfig = serde_json::from_str(&import_content)
|
|
||||||
.map_err(|e| format!("Invalid configuration file: {}", e))?;
|
|
||||||
|
|
||||||
// 备份当前配置
|
|
||||||
let config_path = crate::config::get_app_config_path();
|
|
||||||
let backup_id = create_backup(&config_path)?;
|
|
||||||
|
|
||||||
// 写入新配置到磁盘
|
|
||||||
fs::write(&config_path, &import_content)
|
|
||||||
.map_err(|e| format!("Failed to write configuration: {}", e))?;
|
|
||||||
|
|
||||||
// 更新内存中的状态
|
|
||||||
{
|
|
||||||
let mut config_state = state
|
|
||||||
.config
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| format!("Failed to lock config: {}", e))?;
|
|
||||||
*config_state = new_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"success": true,
|
|
||||||
"message": "Configuration imported successfully",
|
|
||||||
"backupId": backup_id
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存文件对话框
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn save_file_dialog<R: tauri::Runtime>(
|
|
||||||
app: tauri::AppHandle<R>,
|
|
||||||
default_name: String,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
use tauri_plugin_dialog::DialogExt;
|
|
||||||
|
|
||||||
let dialog = app.dialog();
|
|
||||||
let result = dialog
|
|
||||||
.file()
|
|
||||||
.add_filter("JSON", &["json"])
|
|
||||||
.set_file_name(&default_name)
|
|
||||||
.blocking_save_file();
|
|
||||||
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 打开文件对话框
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn open_file_dialog<R: tauri::Runtime>(
|
|
||||||
app: tauri::AppHandle<R>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
use tauri_plugin_dialog::DialogExt;
|
|
||||||
|
|
||||||
let dialog = app.dialog();
|
|
||||||
let result = dialog
|
|
||||||
.file()
|
|
||||||
.add_filter("JSON", &["json"])
|
|
||||||
.blocking_pick_file();
|
|
||||||
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
|
||||||
}
|
|
||||||
@@ -5,16 +5,28 @@ mod claude_plugin;
|
|||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod import_export;
|
mod error;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod provider;
|
mod provider;
|
||||||
|
mod services;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod speedtest;
|
|
||||||
mod usage_script;
|
|
||||||
mod store;
|
mod store;
|
||||||
|
mod usage_script;
|
||||||
|
|
||||||
|
pub use app_config::{AppType, MultiAppConfig};
|
||||||
|
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||||
|
pub use commands::*;
|
||||||
|
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||||
|
pub use error::AppError;
|
||||||
|
pub use mcp::{
|
||||||
|
import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex,
|
||||||
|
};
|
||||||
|
pub use provider::Provider;
|
||||||
|
pub use services::{ConfigService, EndpointLatency, McpService, ProviderService, SpeedtestService};
|
||||||
|
pub use settings::{update_settings, AppSettings};
|
||||||
|
pub use store::AppState;
|
||||||
|
|
||||||
use store::AppState;
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||||
tray::{TrayIconBuilder, TrayIconEvent},
|
tray::{TrayIconBuilder, TrayIconEvent},
|
||||||
@@ -23,21 +35,46 @@ use tauri::{
|
|||||||
use tauri::{ActivationPolicy, RunEvent};
|
use tauri::{ActivationPolicy, RunEvent};
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct TrayTexts {
|
||||||
|
show_main: &'static str,
|
||||||
|
no_provider_hint: &'static str,
|
||||||
|
quit: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrayTexts {
|
||||||
|
fn from_language(language: &str) -> Self {
|
||||||
|
match language {
|
||||||
|
"en" => Self {
|
||||||
|
show_main: "Open main window",
|
||||||
|
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||||
|
quit: "Quit",
|
||||||
|
},
|
||||||
|
_ => Self {
|
||||||
|
show_main: "打开主界面",
|
||||||
|
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||||
|
quit: "退出",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建动态托盘菜单
|
/// 创建动态托盘菜单
|
||||||
fn create_tray_menu(
|
fn create_tray_menu(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
app_state: &AppState,
|
app_state: &AppState,
|
||||||
) -> Result<Menu<tauri::Wry>, String> {
|
) -> Result<Menu<tauri::Wry>, AppError> {
|
||||||
let config = app_state
|
let app_settings = crate::settings::get_settings();
|
||||||
.config
|
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
||||||
.lock()
|
|
||||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
let config = app_state.config.read().map_err(AppError::from)?;
|
||||||
|
|
||||||
let mut menu_builder = MenuBuilder::new(app);
|
let mut menu_builder = MenuBuilder::new(app);
|
||||||
|
|
||||||
// 顶部:打开主界面
|
// 顶部:打开主界面
|
||||||
let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", true, None::<&str>)
|
let show_main_item =
|
||||||
.map_err(|e| format!("创建打开主界面菜单失败: {}", e))?;
|
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {}", e)))?;
|
||||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||||
|
|
||||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||||
@@ -45,7 +82,7 @@ fn create_tray_menu(
|
|||||||
// 添加Claude标题(禁用状态,仅作为分组标识)
|
// 添加Claude标题(禁用状态,仅作为分组标识)
|
||||||
let claude_header =
|
let claude_header =
|
||||||
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
|
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
|
||||||
.map_err(|e| format!("创建Claude标题失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("创建Claude标题失败: {}", e)))?;
|
||||||
menu_builder = menu_builder.item(&claude_header);
|
menu_builder = menu_builder.item(&claude_header);
|
||||||
|
|
||||||
if !claude_manager.providers.is_empty() {
|
if !claude_manager.providers.is_empty() {
|
||||||
@@ -80,7 +117,7 @@ fn create_tray_menu(
|
|||||||
is_current,
|
is_current,
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("创建菜单项失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?;
|
||||||
menu_builder = menu_builder.item(&item);
|
menu_builder = menu_builder.item(&item);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -88,11 +125,11 @@ fn create_tray_menu(
|
|||||||
let empty_hint = MenuItem::with_id(
|
let empty_hint = MenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
"claude_empty",
|
"claude_empty",
|
||||||
" (无供应商,请在主界面添加)",
|
tray_texts.no_provider_hint,
|
||||||
false,
|
false,
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("创建Claude空提示失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("创建Claude空提示失败: {}", e)))?;
|
||||||
menu_builder = menu_builder.item(&empty_hint);
|
menu_builder = menu_builder.item(&empty_hint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +138,7 @@ fn create_tray_menu(
|
|||||||
// 添加Codex标题(禁用状态,仅作为分组标识)
|
// 添加Codex标题(禁用状态,仅作为分组标识)
|
||||||
let codex_header =
|
let codex_header =
|
||||||
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
|
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
|
||||||
.map_err(|e| format!("创建Codex标题失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("创建Codex标题失败: {}", e)))?;
|
||||||
menu_builder = menu_builder.item(&codex_header);
|
menu_builder = menu_builder.item(&codex_header);
|
||||||
|
|
||||||
if !codex_manager.providers.is_empty() {
|
if !codex_manager.providers.is_empty() {
|
||||||
@@ -136,7 +173,7 @@ fn create_tray_menu(
|
|||||||
is_current,
|
is_current,
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("创建菜单项失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?;
|
||||||
menu_builder = menu_builder.item(&item);
|
menu_builder = menu_builder.item(&item);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -144,24 +181,24 @@ fn create_tray_menu(
|
|||||||
let empty_hint = MenuItem::with_id(
|
let empty_hint = MenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
"codex_empty",
|
"codex_empty",
|
||||||
" (无供应商,请在主界面添加)",
|
tray_texts.no_provider_hint,
|
||||||
false,
|
false,
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("创建Codex空提示失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("创建Codex空提示失败: {}", e)))?;
|
||||||
menu_builder = menu_builder.item(&empty_hint);
|
menu_builder = menu_builder.item(&empty_hint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分隔符和退出菜单
|
// 分隔符和退出菜单
|
||||||
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)
|
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||||
.map_err(|e| format!("创建退出菜单失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {}", e)))?;
|
||||||
|
|
||||||
menu_builder = menu_builder.separator().item(&quit_item);
|
menu_builder = menu_builder.separator().item(&quit_item);
|
||||||
|
|
||||||
menu_builder
|
menu_builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("构建菜单失败: {}", e))
|
.map_err(|e| AppError::Message(format!("构建菜单失败: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -212,14 +249,12 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
// 执行切换
|
// 执行切换
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
let provider_id = provider_id.to_string();
|
let provider_id = provider_id.to_string();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
if let Err(e) = switch_provider_internal(
|
if let Err(e) = switch_provider_internal(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
crate::app_config::AppType::Claude,
|
crate::app_config::AppType::Claude,
|
||||||
provider_id,
|
provider_id,
|
||||||
)
|
) {
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::error!("切换Claude供应商失败: {}", e);
|
log::error!("切换Claude供应商失败: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -231,14 +266,12 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
// 执行切换
|
// 执行切换
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
let provider_id = provider_id.to_string();
|
let provider_id = provider_id.to_string();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
if let Err(e) = switch_provider_internal(
|
if let Err(e) = switch_provider_internal(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
crate::app_config::AppType::Codex,
|
crate::app_config::AppType::Codex,
|
||||||
provider_id,
|
provider_id,
|
||||||
)
|
) {
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::error!("切换Codex供应商失败: {}", e);
|
log::error!("切换Codex供应商失败: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -252,24 +285,18 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
//
|
//
|
||||||
|
|
||||||
/// 内部切换供应商函数
|
/// 内部切换供应商函数
|
||||||
async fn switch_provider_internal(
|
fn switch_provider_internal(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
app_type: crate::app_config::AppType,
|
app_type: crate::app_config::AppType,
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), AppError> {
|
||||||
if let Some(app_state) = app.try_state::<AppState>() {
|
if let Some(app_state) = app.try_state::<AppState>() {
|
||||||
// 在使用前先保存需要的值
|
// 在使用前先保存需要的值
|
||||||
let app_type_str = app_type.as_str().to_string();
|
let app_type_str = app_type.as_str().to_string();
|
||||||
let provider_id_clone = provider_id.clone();
|
let provider_id_clone = provider_id.clone();
|
||||||
|
|
||||||
crate::commands::switch_provider(
|
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
|
||||||
app_state.clone(),
|
.map_err(AppError::Message)?;
|
||||||
Some(app_type),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
provider_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 切换成功后重新创建托盘菜单
|
// 切换成功后重新创建托盘菜单
|
||||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||||
@@ -298,14 +325,20 @@ async fn update_tray_menu(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
if let Ok(new_menu) = create_tray_menu(&app, state.inner()) {
|
match create_tray_menu(&app, state.inner()) {
|
||||||
if let Some(tray) = app.tray_by_id("main") {
|
Ok(new_menu) => {
|
||||||
tray.set_menu(Some(new_menu))
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
|
tray.set_menu(Some(new_menu))
|
||||||
return Ok(true);
|
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("创建托盘菜单失败: {}", err);
|
||||||
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -350,8 +383,6 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// 设置全局 AppHandle 以供 Store 使用
|
|
||||||
app_store::set_app_handle(app.handle().clone());
|
|
||||||
// 注册 Updater 插件(桌面端)
|
// 注册 Updater 插件(桌面端)
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
@@ -402,17 +433,20 @@ pub fn run() {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
|
||||||
|
app_store::refresh_app_config_dir_override(app.handle());
|
||||||
|
|
||||||
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
|
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
|
||||||
let app_state = AppState::new();
|
let app_state = AppState::new();
|
||||||
|
|
||||||
// 迁移旧的 app_config_dir 配置到 Store
|
// 迁移旧的 app_config_dir 配置到 Store
|
||||||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(&app.handle()) {
|
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||||||
log::warn!("迁移 app_config_dir 失败: {}", e);
|
log::warn!("迁移 app_config_dir 失败: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档
|
// 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档
|
||||||
{
|
{
|
||||||
let mut config_guard = app_state.config.lock().unwrap();
|
let mut config_guard = app_state.config.write().unwrap();
|
||||||
let migrated = migration::migrate_copies_into_config(&mut config_guard)?;
|
let migrated = migration::migrate_copies_into_config(&mut config_guard)?;
|
||||||
if migrated {
|
if migrated {
|
||||||
log::info!("已将副本文件导入到 config.json,并完成归档");
|
log::info!("已将副本文件导入到 config.json,并完成归档");
|
||||||
@@ -505,10 +539,11 @@ pub fn run() {
|
|||||||
// provider sort order management
|
// provider sort order management
|
||||||
commands::update_providers_sort_order,
|
commands::update_providers_sort_order,
|
||||||
// theirs: config import/export and dialogs
|
// theirs: config import/export and dialogs
|
||||||
import_export::export_config_to_file,
|
commands::export_config_to_file,
|
||||||
import_export::import_config_from_file,
|
commands::import_config_from_file,
|
||||||
import_export::save_file_dialog,
|
commands::save_file_dialog,
|
||||||
import_export::open_file_dialog,
|
commands::open_file_dialog,
|
||||||
|
commands::sync_current_providers_live,
|
||||||
update_tray_menu,
|
update_tray_menu,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -537,4 +572,4 @@ pub fn run() {
|
|||||||
let _ = (app_handle, event);
|
let _ = (app_handle, event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ use serde_json::{json, Value};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
|
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// 基础校验:允许 stdio/http;或省略 type(视为 stdio)。对应必填字段存在
|
/// 基础校验:允许 stdio/http;或省略 type(视为 stdio)。对应必填字段存在
|
||||||
fn validate_server_spec(spec: &Value) -> Result<(), String> {
|
fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
|
||||||
if !spec.is_object() {
|
if !spec.is_object() {
|
||||||
return Err("MCP 服务器连接定义必须为 JSON 对象".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器连接定义必须为 JSON 对象".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||||
// 支持两种:stdio/http;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
|
// 支持两种:stdio/http;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
|
||||||
@@ -14,38 +17,47 @@ fn validate_server_spec(spec: &Value) -> Result<(), String> {
|
|||||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||||
|
|
||||||
if !(is_stdio || is_http) {
|
if !(is_stdio || is_http) {
|
||||||
return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_stdio {
|
if is_stdio {
|
||||||
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
if cmd.trim().is_empty() {
|
if cmd.trim().is_empty() {
|
||||||
return Err("stdio 类型的 MCP 服务器缺少 command 字段".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if is_http {
|
if is_http {
|
||||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
if url.trim().is_empty() {
|
if url.trim().is_empty() {
|
||||||
return Err("http 类型的 MCP 服务器缺少 url 字段".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_mcp_entry(entry: &Value) -> Result<(), String> {
|
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
|
||||||
let obj = entry
|
let obj = entry
|
||||||
.as_object()
|
.as_object()
|
||||||
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||||
|
|
||||||
let server = obj
|
let server = obj
|
||||||
.get("server")
|
.get("server")
|
||||||
.ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?;
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||||
validate_server_spec(server)?;
|
validate_server_spec(server)?;
|
||||||
|
|
||||||
for key in ["name", "description", "homepage", "docs"] {
|
for key in ["name", "description", "homepage", "docs"] {
|
||||||
if let Some(val) = obj.get(key) {
|
if let Some(val) = obj.get(key) {
|
||||||
if !val.is_string() {
|
if !val.is_string() {
|
||||||
return Err(format!("MCP 服务器 {} 必须为字符串", key));
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"MCP 服务器 {} 必须为字符串",
|
||||||
|
key
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,15 +65,19 @@ fn validate_mcp_entry(entry: &Value) -> Result<(), String> {
|
|||||||
if let Some(tags) = obj.get("tags") {
|
if let Some(tags) = obj.get("tags") {
|
||||||
let arr = tags
|
let arr = tags
|
||||||
.as_array()
|
.as_array()
|
||||||
.ok_or_else(|| "MCP 服务器 tags 必须为字符串数组".to_string())?;
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?;
|
||||||
if !arr.iter().all(|item| item.is_string()) {
|
if !arr.iter().all(|item| item.is_string()) {
|
||||||
return Err("MCP 服务器 tags 必须为字符串数组".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 tags 必须为字符串数组".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(enabled) = obj.get("enabled") {
|
if let Some(enabled) = obj.get("enabled") {
|
||||||
if !enabled.is_boolean() {
|
if !enabled.is_boolean() {
|
||||||
return Err("MCP 服务器 enabled 必须为布尔值".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 enabled 必须为布尔值".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,16 +175,18 @@ pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usiz
|
|||||||
normalize_server_keys(servers)
|
normalize_server_keys(servers)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_server_spec(entry: &Value) -> Result<Value, String> {
|
fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
|
||||||
let obj = entry
|
let obj = entry
|
||||||
.as_object()
|
.as_object()
|
||||||
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||||
let server = obj
|
let server = obj
|
||||||
.get("server")
|
.get("server")
|
||||||
.ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?;
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||||
|
|
||||||
if !server.is_object() {
|
if !server.is_object() {
|
||||||
return Err("MCP 服务器 server 字段必须为 JSON 对象".into());
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 server 字段必须为 JSON 对象".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(server.clone())
|
Ok(server.clone())
|
||||||
@@ -227,9 +245,9 @@ pub fn upsert_in_config_for(
|
|||||||
app: &AppType,
|
app: &AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
spec: Value,
|
spec: Value,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, AppError> {
|
||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err("MCP 服务器 ID 不能为空".into());
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
}
|
}
|
||||||
normalize_servers_for(config, app);
|
normalize_servers_for(config, app);
|
||||||
validate_mcp_entry(&spec)?;
|
validate_mcp_entry(&spec)?;
|
||||||
@@ -237,16 +255,16 @@ pub fn upsert_in_config_for(
|
|||||||
let mut entry_obj = spec
|
let mut entry_obj = spec
|
||||||
.as_object()
|
.as_object()
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||||
if let Some(existing_id) = entry_obj.get("id") {
|
if let Some(existing_id) = entry_obj.get("id") {
|
||||||
let Some(existing_id_str) = existing_id.as_str() else {
|
let Some(existing_id_str) = existing_id.as_str() else {
|
||||||
return Err("MCP 服务器 id 必须为字符串".into());
|
return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into()));
|
||||||
};
|
};
|
||||||
if existing_id_str != id {
|
if existing_id_str != id {
|
||||||
return Err(format!(
|
return Err(AppError::McpValidation(format!(
|
||||||
"MCP 服务器条目中的 id '{}' 与参数 id '{}' 不一致",
|
"MCP 服务器条目中的 id '{}' 与参数 id '{}' 不一致",
|
||||||
existing_id_str, id
|
existing_id_str, id
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
entry_obj.insert(String::from("id"), json!(id));
|
entry_obj.insert(String::from("id"), json!(id));
|
||||||
@@ -265,24 +283,24 @@ pub fn delete_in_config_for(
|
|||||||
config: &mut MultiAppConfig,
|
config: &mut MultiAppConfig,
|
||||||
app: &AppType,
|
app: &AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, AppError> {
|
||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err("MCP 服务器 ID 不能为空".into());
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
}
|
}
|
||||||
normalize_servers_for(config, app);
|
normalize_servers_for(config, app);
|
||||||
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
|
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
|
||||||
Ok(existed)
|
Ok(existed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置启用状态并同步到 ~/.claude.json
|
/// 设置启用状态(不执行落盘或文件同步)
|
||||||
pub fn set_enabled_and_sync_for(
|
pub fn set_enabled_flag_for(
|
||||||
config: &mut MultiAppConfig,
|
config: &mut MultiAppConfig,
|
||||||
app: &AppType,
|
app: &AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, AppError> {
|
||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err("MCP 服务器 ID 不能为空".into());
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
}
|
}
|
||||||
normalize_servers_for(config, app);
|
normalize_servers_for(config, app);
|
||||||
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
|
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
|
||||||
@@ -290,7 +308,7 @@ pub fn set_enabled_and_sync_for(
|
|||||||
let mut obj = spec
|
let mut obj = spec
|
||||||
.as_object()
|
.as_object()
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?;
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?;
|
||||||
obj.insert("enabled".into(), json!(enabled));
|
obj.insert("enabled".into(), json!(enabled));
|
||||||
*spec = Value::Object(obj);
|
*spec = Value::Object(obj);
|
||||||
} else {
|
} else {
|
||||||
@@ -298,34 +316,23 @@ pub fn set_enabled_and_sync_for(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步启用项
|
|
||||||
match app {
|
|
||||||
AppType::Claude => {
|
|
||||||
// 将启用项投影到 ~/.claude.json
|
|
||||||
sync_enabled_to_claude(config)?;
|
|
||||||
}
|
|
||||||
AppType::Codex => {
|
|
||||||
// 将启用项投影到 ~/.codex/config.toml
|
|
||||||
sync_enabled_to_codex(config)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
|
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
|
||||||
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), String> {
|
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||||
let enabled = collect_enabled_servers(&config.mcp.claude);
|
let enabled = collect_enabled_servers(&config.mcp.claude);
|
||||||
crate::claude_mcp::set_mcp_servers_map(&enabled)
|
crate::claude_mcp::set_mcp_servers_map(&enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 ~/.claude.json 导入 mcpServers 到 config.json(设为 enabled=true)。
|
/// 从 ~/.claude.json 导入 mcpServers 到 config.json(设为 enabled=true)。
|
||||||
/// 已存在的项仅强制 enabled=true,不覆盖其他字段。
|
/// 已存在的项仅强制 enabled=true,不覆盖其他字段。
|
||||||
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, String> {
|
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||||
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
||||||
let Some(text) = text_opt else { return Ok(0) };
|
let Some(text) = text_opt else { return Ok(0) };
|
||||||
let mut changed = normalize_servers_for(config, &AppType::Claude);
|
let mut changed = normalize_servers_for(config, &AppType::Claude);
|
||||||
let v: Value =
|
let v: Value = serde_json::from_str(&text)
|
||||||
serde_json::from_str(&text).map_err(|e| format!("解析 ~/.claude.json 失败: {}", e))?;
|
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {}", e)))?;
|
||||||
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||||
return Ok(changed);
|
return Ok(changed);
|
||||||
};
|
};
|
||||||
@@ -394,15 +401,15 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, String>
|
|||||||
/// 从 ~/.codex/config.toml 导入 MCP 到 config.json(Codex 作用域),并将导入项设为 enabled=true。
|
/// 从 ~/.codex/config.toml 导入 MCP 到 config.json(Codex 作用域),并将导入项设为 enabled=true。
|
||||||
/// 支持两种 schema:[mcp.servers.<id>] 与 [mcp_servers.<id>]。
|
/// 支持两种 schema:[mcp.servers.<id>] 与 [mcp_servers.<id>]。
|
||||||
/// 已存在的项仅强制 enabled=true,不覆盖其他字段。
|
/// 已存在的项仅强制 enabled=true,不覆盖其他字段。
|
||||||
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, String> {
|
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||||
let text = crate::codex_config::read_and_validate_codex_config_text()?;
|
let text = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let mut changed_total = normalize_servers_for(config, &AppType::Codex);
|
let mut changed_total = normalize_servers_for(config, &AppType::Codex);
|
||||||
|
|
||||||
let root: toml::Table =
|
let root: toml::Table = toml::from_str(&text)
|
||||||
toml::from_str(&text).map_err(|e| format!("解析 ~/.codex/config.toml 失败: {}", e))?;
|
.map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {}", e)))?;
|
||||||
|
|
||||||
// helper:处理一组 servers 表
|
// helper:处理一组 servers 表
|
||||||
let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
|
let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
|
||||||
@@ -565,168 +572,149 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, String> {
|
|||||||
/// - 读取现有 config.toml;若语法无效则报错,不尝试覆盖
|
/// - 读取现有 config.toml;若语法无效则报错,不尝试覆盖
|
||||||
/// - 仅更新 `mcp.servers` 或 `mcp_servers` 子表,保留 `mcp` 其它键
|
/// - 仅更新 `mcp.servers` 或 `mcp_servers` 子表,保留 `mcp` 其它键
|
||||||
/// - 仅写入启用项;无启用项时清理对应子表
|
/// - 仅写入启用项;无启用项时清理对应子表
|
||||||
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> {
|
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||||
use toml::{value::Value as TomlValue, Table as TomlTable};
|
use toml_edit::{DocumentMut, Item, Table};
|
||||||
|
|
||||||
// 1) 收集启用项(Codex 维度)
|
// 1) 收集启用项(Codex 维度)
|
||||||
let enabled = collect_enabled_servers(&config.mcp.codex);
|
let enabled = collect_enabled_servers(&config.mcp.codex);
|
||||||
|
|
||||||
// 2) 读取现有 config.toml 并解析为 Table(允许空文件)
|
// 2) 读取现有 config.toml 文本;保持无效 TOML 的错误返回(不覆盖文件)
|
||||||
let base_text = crate::codex_config::read_and_validate_codex_config_text()?;
|
let base_text = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||||
let mut root: TomlTable = if base_text.trim().is_empty() {
|
|
||||||
TomlTable::new()
|
// 3) 使用 toml_edit 解析(允许空文件)
|
||||||
|
let mut doc: DocumentMut = if base_text.trim().is_empty() {
|
||||||
|
DocumentMut::default()
|
||||||
} else {
|
} else {
|
||||||
toml::from_str::<TomlTable>(&base_text)
|
base_text
|
||||||
.map_err(|e| format!("解析 config.toml 失败: {}", e))?
|
.parse::<DocumentMut>()
|
||||||
|
.map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {}", e)))?
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers;优先沿用已有风格,默认 mcp_servers)
|
enum Target {
|
||||||
let prefer_mcp_servers = root.get("mcp_servers").is_some() || root.get("mcp").is_none();
|
McpServers, // 顶层 mcp_servers
|
||||||
if enabled.is_empty() {
|
McpDotServers, // mcp.servers
|
||||||
// 无启用项:移除两种节点
|
}
|
||||||
// 清除 mcp.servers,但保留其他 mcp 字段
|
|
||||||
let mut should_drop_mcp = false;
|
|
||||||
if let Some(mcp_val) = root.get_mut("mcp") {
|
|
||||||
match mcp_val {
|
|
||||||
TomlValue::Table(tbl) => {
|
|
||||||
tbl.remove("servers");
|
|
||||||
should_drop_mcp = tbl.is_empty();
|
|
||||||
}
|
|
||||||
_ => should_drop_mcp = true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if should_drop_mcp {
|
|
||||||
root.remove("mcp");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除顶层 mcp_servers
|
// 4) 选择目标风格:优先沿用既有子表;其次在 mcp 表下新建;最后退回顶层 mcp_servers
|
||||||
root.remove("mcp_servers");
|
let has_mcp_dot_servers = doc
|
||||||
|
.get("mcp")
|
||||||
|
.and_then(|m| m.get("servers"))
|
||||||
|
.and_then(|s| s.as_table_like())
|
||||||
|
.is_some();
|
||||||
|
let has_mcp_servers = doc
|
||||||
|
.get("mcp_servers")
|
||||||
|
.and_then(|s| s.as_table_like())
|
||||||
|
.is_some();
|
||||||
|
let mcp_is_table = doc.get("mcp").and_then(|m| m.as_table_like()).is_some();
|
||||||
|
|
||||||
|
let target = if has_mcp_dot_servers {
|
||||||
|
Target::McpDotServers
|
||||||
|
} else if has_mcp_servers {
|
||||||
|
Target::McpServers
|
||||||
|
} else if mcp_is_table {
|
||||||
|
Target::McpDotServers
|
||||||
} else {
|
} else {
|
||||||
let mut servers_tbl = TomlTable::new();
|
Target::McpServers
|
||||||
|
};
|
||||||
|
|
||||||
for (id, spec) in enabled.iter() {
|
// 构造目标 servers 表(稳定的键顺序)
|
||||||
let mut s = TomlTable::new();
|
let build_servers_table = || -> Table {
|
||||||
|
let mut servers = Table::new();
|
||||||
// 类型(缺省视为 stdio)
|
let mut ids: Vec<_> = enabled.keys().cloned().collect();
|
||||||
|
ids.sort();
|
||||||
|
for id in ids {
|
||||||
|
let spec = enabled.get(&id).expect("spec must exist");
|
||||||
|
let mut t = Table::new();
|
||||||
let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||||
s.insert("type".into(), TomlValue::String(typ.to_string()));
|
t["type"] = toml_edit::value(typ);
|
||||||
|
|
||||||
match typ {
|
match typ {
|
||||||
"stdio" => {
|
"stdio" => {
|
||||||
let cmd = spec
|
let cmd = spec.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.get("command")
|
t["command"] = toml_edit::value(cmd);
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
s.insert("command".into(), TomlValue::String(cmd));
|
|
||||||
|
|
||||||
if let Some(args) = spec.get("args").and_then(|v| v.as_array()) {
|
if let Some(args) = spec.get("args").and_then(|v| v.as_array()) {
|
||||||
let arr = args
|
let mut arr_v = toml_edit::Array::default();
|
||||||
.iter()
|
for a in args.iter().filter_map(|x| x.as_str()) {
|
||||||
.filter_map(|x| x.as_str())
|
arr_v.push(a);
|
||||||
.map(|x| TomlValue::String(x.to_string()))
|
}
|
||||||
.collect::<Vec<_>>();
|
if !arr_v.is_empty() {
|
||||||
if !arr.is_empty() {
|
t["args"] = toml_edit::Item::Value(toml_edit::Value::Array(arr_v));
|
||||||
s.insert("args".into(), TomlValue::Array(arr));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) {
|
if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) {
|
||||||
if !cwd.trim().is_empty() {
|
if !cwd.trim().is_empty() {
|
||||||
s.insert("cwd".into(), TomlValue::String(cwd.to_string()));
|
t["cwd"] = toml_edit::value(cwd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(env) = spec.get("env").and_then(|v| v.as_object()) {
|
if let Some(env) = spec.get("env").and_then(|v| v.as_object()) {
|
||||||
let mut env_tbl = TomlTable::new();
|
let mut env_tbl = Table::new();
|
||||||
for (k, v) in env.iter() {
|
for (k, v) in env.iter() {
|
||||||
if let Some(sv) = v.as_str() {
|
if let Some(s) = v.as_str() {
|
||||||
env_tbl.insert(k.clone(), TomlValue::String(sv.to_string()));
|
env_tbl[&k[..]] = toml_edit::value(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !env_tbl.is_empty() {
|
if !env_tbl.is_empty() {
|
||||||
s.insert("env".into(), TomlValue::Table(env_tbl));
|
t["env"] = Item::Table(env_tbl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"http" => {
|
"http" => {
|
||||||
let url = spec
|
let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.get("url")
|
t["url"] = toml_edit::value(url);
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
s.insert("url".into(), TomlValue::String(url));
|
|
||||||
|
|
||||||
if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) {
|
if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) {
|
||||||
let mut h_tbl = TomlTable::new();
|
let mut h_tbl = Table::new();
|
||||||
for (k, v) in headers.iter() {
|
for (k, v) in headers.iter() {
|
||||||
if let Some(sv) = v.as_str() {
|
if let Some(s) = v.as_str() {
|
||||||
h_tbl.insert(k.clone(), TomlValue::String(sv.to_string()));
|
h_tbl[&k[..]] = toml_edit::value(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !h_tbl.is_empty() {
|
if !h_tbl.is_empty() {
|
||||||
s.insert("headers".into(), TomlValue::Table(h_tbl));
|
t["headers"] = Item::Table(h_tbl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
servers[&id[..]] = Item::Table(t);
|
||||||
servers_tbl.insert(id.clone(), TomlValue::Table(s));
|
|
||||||
}
|
}
|
||||||
|
servers
|
||||||
|
};
|
||||||
|
|
||||||
let servers_value = TomlValue::Table(servers_tbl.clone());
|
// 5) 应用更新:仅就地更新目标子表;避免改动其它键/注释/空白
|
||||||
|
if enabled.is_empty() {
|
||||||
if prefer_mcp_servers {
|
// 无启用项:移除两种 servers 表(如果存在),但保留 mcp 其它字段
|
||||||
root.insert("mcp_servers".into(), servers_value);
|
if let Some(mcp_item) = doc.get_mut("mcp") {
|
||||||
|
if let Some(tbl) = mcp_item.as_table_like_mut() {
|
||||||
// 若存在 mcp,则仅移除 servers 字段,保留其他键
|
tbl.remove("servers");
|
||||||
let mut should_drop_mcp = false;
|
}
|
||||||
if let Some(mcp_val) = root.get_mut("mcp") {
|
}
|
||||||
match mcp_val {
|
doc.as_table_mut().remove("mcp_servers");
|
||||||
TomlValue::Table(tbl) => {
|
} else {
|
||||||
|
let servers_tbl = build_servers_table();
|
||||||
|
match target {
|
||||||
|
Target::McpDotServers => {
|
||||||
|
// 确保 mcp 为表
|
||||||
|
if doc.get("mcp").and_then(|m| m.as_table_like()).is_none() {
|
||||||
|
doc["mcp"] = Item::Table(Table::new());
|
||||||
|
}
|
||||||
|
doc["mcp"]["servers"] = Item::Table(servers_tbl);
|
||||||
|
// 去重:若存在顶层 mcp_servers,则移除以避免重复定义
|
||||||
|
doc.as_table_mut().remove("mcp_servers");
|
||||||
|
}
|
||||||
|
Target::McpServers => {
|
||||||
|
doc["mcp_servers"] = Item::Table(servers_tbl);
|
||||||
|
// 去重:若存在 mcp.servers,则移除该子表,保留 mcp 其它键
|
||||||
|
if let Some(mcp_item) = doc.get_mut("mcp") {
|
||||||
|
if let Some(tbl) = mcp_item.as_table_like_mut() {
|
||||||
tbl.remove("servers");
|
tbl.remove("servers");
|
||||||
should_drop_mcp = tbl.is_empty();
|
|
||||||
}
|
|
||||||
_ => should_drop_mcp = true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if should_drop_mcp {
|
|
||||||
root.remove("mcp");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let mut inserted = false;
|
|
||||||
|
|
||||||
if let Some(mcp_val) = root.get_mut("mcp") {
|
|
||||||
match mcp_val {
|
|
||||||
TomlValue::Table(tbl) => {
|
|
||||||
tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone()));
|
|
||||||
inserted = true;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let mut mcp_tbl = TomlTable::new();
|
|
||||||
mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone()));
|
|
||||||
*mcp_val = TomlValue::Table(mcp_tbl);
|
|
||||||
inserted = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !inserted {
|
|
||||||
let mut mcp_tbl = TomlTable::new();
|
|
||||||
mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl));
|
|
||||||
root.insert("mcp".into(), TomlValue::Table(mcp_tbl));
|
|
||||||
}
|
|
||||||
|
|
||||||
root.remove("mcp_servers");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) 序列化并写回 config.toml(仅改 TOML,不触碰 auth.json)
|
// 6) 写回(仅改 TOML,不触碰 auth.json);toml_edit 会尽量保留未改区域的注释/空白/顺序
|
||||||
let new_text = toml::to_string(&TomlValue::Table(root))
|
let new_text = doc.to_string();
|
||||||
.map_err(|e| format!("序列化 config.toml 失败: {}", e))?;
|
|
||||||
let path = crate::codex_config::get_codex_config_path();
|
let path = crate::codex_config::get_codex_config_path();
|
||||||
crate::config::write_text_file(&path, &new_text)?;
|
crate::config::write_text_file(&path, &new_text)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::app_config::{AppType, MultiAppConfig};
|
|||||||
use crate::config::{
|
use crate::config::{
|
||||||
archive_file, delete_file, get_app_config_dir, get_app_config_path, get_claude_config_dir,
|
archive_file, delete_file, get_app_config_dir, get_app_config_path, get_claude_config_dir,
|
||||||
};
|
};
|
||||||
|
use crate::error::AppError;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -144,11 +145,11 @@ fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)>
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> {
|
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, AppError> {
|
||||||
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
|
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
|
||||||
let marker = get_marker_path();
|
let marker = get_marker_path();
|
||||||
if let Some(parent) = marker.parent() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
}
|
}
|
||||||
if marker.exists() {
|
if marker.exists() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -158,7 +159,7 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
|
|||||||
let codex_items = scan_codex_copies();
|
let codex_items = scan_codex_copies();
|
||||||
if claude_items.is_empty() && codex_items.is_empty() {
|
if claude_items.is_empty() && codex_items.is_empty() {
|
||||||
// 即便没有可迁移项,也写入标记避免每次扫描
|
// 即便没有可迁移项,也写入标记避免每次扫描
|
||||||
fs::write(&marker, b"no-copies").map_err(|e| format!("写入迁移标记失败: {}", e))?;
|
fs::write(&marker, b"no-copies").map_err(|e| AppError::io(&marker, e))?;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +382,7 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
|
|||||||
log::info!("迁移阶段已去重重复供应商 {} 个", removed);
|
log::info!("迁移阶段已去重重复供应商 {} 个", removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::write(&marker, b"done").map_err(|e| format!("写入迁移标记失败: {}", e))?;
|
fs::write(&marker, b"done").map_err(|e| AppError::io(&marker, e))?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
229
src-tauri/src/services/config.rs
Normal file
229
src-tauri/src/services/config.rs
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
use crate::app_config::{AppType, MultiAppConfig};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const MAX_BACKUPS: usize = 10;
|
||||||
|
|
||||||
|
/// 配置导入导出相关业务逻辑
|
||||||
|
pub struct ConfigService;
|
||||||
|
|
||||||
|
impl ConfigService {
|
||||||
|
/// 为当前 config.json 创建备份,返回备份 ID(若文件不存在则返回空字符串)。
|
||||||
|
pub fn create_backup(config_path: &Path) -> Result<String, AppError> {
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||||
|
let backup_id = format!("backup_{}", timestamp);
|
||||||
|
|
||||||
|
let backup_dir = config_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| AppError::Config("Invalid config path".into()))?
|
||||||
|
.join("backups");
|
||||||
|
|
||||||
|
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
||||||
|
|
||||||
|
let backup_path = backup_dir.join(format!("{}.json", backup_id));
|
||||||
|
let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?;
|
||||||
|
fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?;
|
||||||
|
|
||||||
|
Self::cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;
|
||||||
|
|
||||||
|
Ok(backup_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_old_backups(backup_dir: &Path, retain: usize) -> Result<(), AppError> {
|
||||||
|
if retain == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = match fs::read_dir(backup_dir) {
|
||||||
|
Ok(iter) => iter
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.map(|ext| ext == "json")
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if entries.len() <= retain {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let remove_count = entries.len().saturating_sub(retain);
|
||||||
|
let mut sorted = entries;
|
||||||
|
|
||||||
|
sorted.sort_by(|a, b| {
|
||||||
|
let a_time = a.metadata().and_then(|m| m.modified()).ok();
|
||||||
|
let b_time = b.metadata().and_then(|m| m.modified()).ok();
|
||||||
|
a_time.cmp(&b_time)
|
||||||
|
});
|
||||||
|
|
||||||
|
for entry in sorted.into_iter().take(remove_count) {
|
||||||
|
if let Err(err) = fs::remove_file(entry.path()) {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to remove old backup {}: {}",
|
||||||
|
entry.path().display(),
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将当前 config.json 拷贝到目标路径。
|
||||||
|
pub fn export_config_to_path(target_path: &Path) -> Result<(), AppError> {
|
||||||
|
let config_path = crate::config::get_app_config_path();
|
||||||
|
let config_content =
|
||||||
|
fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
fs::write(target_path, config_content).map_err(|e| AppError::io(target_path, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从磁盘文件加载配置并写回 config.json,返回备份 ID 及新配置。
|
||||||
|
pub fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> {
|
||||||
|
let import_content =
|
||||||
|
fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?;
|
||||||
|
|
||||||
|
let new_config: MultiAppConfig =
|
||||||
|
serde_json::from_str(&import_content).map_err(|e| AppError::json(file_path, e))?;
|
||||||
|
|
||||||
|
let config_path = crate::config::get_app_config_path();
|
||||||
|
let backup_id = Self::create_backup(&config_path)?;
|
||||||
|
|
||||||
|
fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
|
Ok((new_config, backup_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将外部配置文件内容加载并写入应用状态。
|
||||||
|
pub fn import_config_from_path(file_path: &Path, state: &AppState) -> Result<String, AppError> {
|
||||||
|
let (new_config, backup_id) = Self::load_config_for_import(file_path)?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = state.config.write().map_err(AppError::from)?;
|
||||||
|
*guard = new_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(backup_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步当前供应商到对应的 live 配置。
|
||||||
|
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
||||||
|
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||||
|
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_current_provider_for_app(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
app_type: &AppType,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let (current_id, provider) = {
|
||||||
|
let manager = match config.get_manager(app_type) {
|
||||||
|
Some(manager) => manager,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if manager.current.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_id = manager.current.clone();
|
||||||
|
let provider = match manager.providers.get(¤t_id) {
|
||||||
|
Some(provider) => provider.clone(),
|
||||||
|
None => {
|
||||||
|
log::warn!(
|
||||||
|
"当前应用 {:?} 的供应商 {} 不存在,跳过 live 同步",
|
||||||
|
app_type,
|
||||||
|
current_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(current_id, provider)
|
||||||
|
};
|
||||||
|
|
||||||
|
match app_type {
|
||||||
|
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||||
|
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_codex_live(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
provider_id: &str,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||||
|
AppError::Config(format!("供应商 {} 的 Codex 配置必须是对象", provider_id))
|
||||||
|
})?;
|
||||||
|
let auth = settings.get("auth").ok_or_else(|| {
|
||||||
|
AppError::Config(format!(
|
||||||
|
"供应商 {} 的 Codex 配置缺少 auth 字段",
|
||||||
|
provider_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !auth.is_object() {
|
||||||
|
return Err(AppError::Config(format!(
|
||||||
|
"供应商 {} 的 Codex auth 配置必须是 JSON 对象",
|
||||||
|
provider_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||||
|
|
||||||
|
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||||
|
crate::mcp::sync_enabled_to_codex(config)?;
|
||||||
|
|
||||||
|
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
if let Some(obj) = target.settings_config.as_object_mut() {
|
||||||
|
obj.insert(
|
||||||
|
"config".to_string(),
|
||||||
|
serde_json::Value::String(cfg_text_after),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_claude_live(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
provider_id: &str,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
use crate::config::{read_json_file, write_json_file};
|
||||||
|
|
||||||
|
let settings_path = crate::config::get_claude_settings_path();
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_file(&settings_path, &provider.settings_config)?;
|
||||||
|
|
||||||
|
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
target.settings_config = live_after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src-tauri/src/services/mcp.rs
Normal file
191
src-tauri/src/services/mcp.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::app_config::{AppType, MultiAppConfig};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::mcp;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// MCP 相关业务逻辑
|
||||||
|
pub struct McpService;
|
||||||
|
|
||||||
|
impl McpService {
|
||||||
|
/// 获取指定应用的 MCP 服务器快照,并在必要时回写归一化后的配置。
|
||||||
|
pub fn get_servers(state: &AppState, app: AppType) -> Result<HashMap<String, Value>, AppError> {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let (snapshot, normalized) = mcp::get_servers_snapshot_for(&mut cfg, &app);
|
||||||
|
drop(cfg);
|
||||||
|
if normalized > 0 {
|
||||||
|
state.save()?;
|
||||||
|
}
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 config.json 中新增或更新指定 MCP 服务器,并按需同步到对应客户端。
|
||||||
|
pub fn upsert_server(
|
||||||
|
state: &AppState,
|
||||||
|
app: AppType,
|
||||||
|
id: &str,
|
||||||
|
spec: Value,
|
||||||
|
sync_other_side: bool,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let (changed, snapshot, sync_claude, sync_codex): (
|
||||||
|
bool,
|
||||||
|
Option<MultiAppConfig>,
|
||||||
|
bool,
|
||||||
|
bool,
|
||||||
|
) = {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let changed = mcp::upsert_in_config_for(&mut cfg, &app, id, spec)?;
|
||||||
|
|
||||||
|
// 修复:默认启用(unwrap_or(true))
|
||||||
|
// 新增的 MCP 如果缺少 enabled 字段,应该默认为启用状态
|
||||||
|
let enabled = cfg
|
||||||
|
.mcp_for(&app)
|
||||||
|
.servers
|
||||||
|
.get(id)
|
||||||
|
.and_then(|entry| entry.get("enabled"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
let mut sync_claude = matches!(app, AppType::Claude) && enabled;
|
||||||
|
let mut sync_codex = matches!(app, AppType::Codex) && enabled;
|
||||||
|
|
||||||
|
// 修复:sync_other_side=true 时,先将 MCP 复制到另一侧,然后强制同步
|
||||||
|
// 这才是"同步到另一侧"的正确语义:将 MCP 跨应用复制
|
||||||
|
if sync_other_side {
|
||||||
|
// 获取当前 MCP 条目的克隆(刚刚插入的,不可能失败)
|
||||||
|
let current_entry = cfg
|
||||||
|
.mcp_for(&app)
|
||||||
|
.servers
|
||||||
|
.get(id)
|
||||||
|
.cloned()
|
||||||
|
.expect("刚刚插入的 MCP 条目必定存在");
|
||||||
|
|
||||||
|
// 将该 MCP 复制到另一侧的 servers
|
||||||
|
let other_app = match app {
|
||||||
|
AppType::Claude => AppType::Codex,
|
||||||
|
AppType::Codex => AppType::Claude,
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg.mcp_for_mut(&other_app)
|
||||||
|
.servers
|
||||||
|
.insert(id.to_string(), current_entry);
|
||||||
|
|
||||||
|
// 强制同步另一侧
|
||||||
|
match app {
|
||||||
|
AppType::Claude => sync_codex = true,
|
||||||
|
AppType::Codex => sync_claude = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = if sync_claude || sync_codex {
|
||||||
|
Some(cfg.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
(changed, snapshot, sync_claude, sync_codex)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保持原有行为:始终尝试持久化,避免遗漏 normalize 带来的隐式变更
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
if let Some(snapshot) = snapshot {
|
||||||
|
if sync_claude {
|
||||||
|
mcp::sync_enabled_to_claude(&snapshot)?;
|
||||||
|
}
|
||||||
|
if sync_codex {
|
||||||
|
mcp::sync_enabled_to_codex(&snapshot)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 config.json 中的 MCP 服务器条目,并同步客户端配置。
|
||||||
|
pub fn delete_server(state: &AppState, app: AppType, id: &str) -> Result<bool, AppError> {
|
||||||
|
let (existed, snapshot): (bool, Option<MultiAppConfig>) = {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let existed = mcp::delete_in_config_for(&mut cfg, &app, id)?;
|
||||||
|
let snapshot = if existed { Some(cfg.clone()) } else { None };
|
||||||
|
(existed, snapshot)
|
||||||
|
};
|
||||||
|
if existed {
|
||||||
|
state.save()?;
|
||||||
|
if let Some(snapshot) = snapshot {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
||||||
|
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(existed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 MCP 启用状态,并同步到客户端配置。
|
||||||
|
pub fn set_enabled(
|
||||||
|
state: &AppState,
|
||||||
|
app: AppType,
|
||||||
|
id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let (existed, snapshot): (bool, Option<MultiAppConfig>) = {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let existed = mcp::set_enabled_flag_for(&mut cfg, &app, id, enabled)?;
|
||||||
|
let snapshot = if existed { Some(cfg.clone()) } else { None };
|
||||||
|
(existed, snapshot)
|
||||||
|
};
|
||||||
|
|
||||||
|
if existed {
|
||||||
|
state.save()?;
|
||||||
|
if let Some(snapshot) = snapshot {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
||||||
|
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(existed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动同步已启用的 MCP 服务器到客户端配置。
|
||||||
|
pub fn sync_enabled(state: &AppState, app: AppType) -> Result<(), AppError> {
|
||||||
|
let (snapshot, normalized): (MultiAppConfig, usize) = {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let normalized = mcp::normalize_servers_for(&mut cfg, &app);
|
||||||
|
(cfg.clone(), normalized)
|
||||||
|
};
|
||||||
|
if normalized > 0 {
|
||||||
|
state.save()?;
|
||||||
|
}
|
||||||
|
match app {
|
||||||
|
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
||||||
|
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Claude 客户端配置导入 MCP 定义。
|
||||||
|
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let changed = mcp::import_from_claude(&mut cfg)?;
|
||||||
|
drop(cfg);
|
||||||
|
if changed > 0 {
|
||||||
|
state.save()?;
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Codex 客户端配置导入 MCP 定义。
|
||||||
|
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let changed = mcp::import_from_codex(&mut cfg)?;
|
||||||
|
drop(cfg);
|
||||||
|
if changed > 0 {
|
||||||
|
state.save()?;
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src-tauri/src/services/mod.rs
Normal file
9
src-tauri/src/services/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod provider;
|
||||||
|
pub mod speedtest;
|
||||||
|
|
||||||
|
pub use config::ConfigService;
|
||||||
|
pub use mcp::McpService;
|
||||||
|
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||||
|
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||||
1111
src-tauri/src/services/provider.rs
Normal file
1111
src-tauri/src/services/provider.rs
Normal file
File diff suppressed because it is too large
Load Diff
168
src-tauri/src/services/speedtest.rs
Normal file
168
src-tauri/src/services/speedtest.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use futures::future::join_all;
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_SECS: u64 = 8;
|
||||||
|
const MAX_TIMEOUT_SECS: u64 = 30;
|
||||||
|
const MIN_TIMEOUT_SECS: u64 = 2;
|
||||||
|
|
||||||
|
/// 端点测速结果
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct EndpointLatency {
|
||||||
|
pub url: String,
|
||||||
|
pub latency: Option<u128>,
|
||||||
|
pub status: Option<u16>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 网络测速相关业务
|
||||||
|
pub struct SpeedtestService;
|
||||||
|
|
||||||
|
impl SpeedtestService {
|
||||||
|
/// 测试一组端点的响应延迟。
|
||||||
|
pub async fn test_endpoints(
|
||||||
|
urls: Vec<String>,
|
||||||
|
timeout_secs: Option<u64>,
|
||||||
|
) -> Result<Vec<EndpointLatency>, AppError> {
|
||||||
|
if urls.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = Self::sanitize_timeout(timeout_secs);
|
||||||
|
let client = Self::build_client(timeout)?;
|
||||||
|
|
||||||
|
let tasks = urls.into_iter().map(|raw_url| {
|
||||||
|
let client = client.clone();
|
||||||
|
async move {
|
||||||
|
let trimmed = raw_url.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return EndpointLatency {
|
||||||
|
url: raw_url,
|
||||||
|
latency: None,
|
||||||
|
status: None,
|
||||||
|
error: Some("URL 不能为空".to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed_url = match Url::parse(&trimmed) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(err) => {
|
||||||
|
return EndpointLatency {
|
||||||
|
url: trimmed,
|
||||||
|
latency: None,
|
||||||
|
status: None,
|
||||||
|
error: Some(format!("URL 无效: {err}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
||||||
|
let _ = client.get(parsed_url.clone()).send().await;
|
||||||
|
|
||||||
|
// 第二次请求开始计时,并将其作为结果返回。
|
||||||
|
let start = Instant::now();
|
||||||
|
match client.get(parsed_url).send().await {
|
||||||
|
Ok(resp) => EndpointLatency {
|
||||||
|
url: trimmed,
|
||||||
|
latency: Some(start.elapsed().as_millis()),
|
||||||
|
status: Some(resp.status().as_u16()),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
let status = err.status().map(|s| s.as_u16());
|
||||||
|
let error_message = if err.is_timeout() {
|
||||||
|
"请求超时".to_string()
|
||||||
|
} else if err.is_connect() {
|
||||||
|
"连接失败".to_string()
|
||||||
|
} else {
|
||||||
|
err.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
EndpointLatency {
|
||||||
|
url: trimmed,
|
||||||
|
latency: None,
|
||||||
|
status,
|
||||||
|
error: Some(error_message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(join_all(tasks).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
||||||
|
Client::builder()
|
||||||
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(5))
|
||||||
|
.user_agent("cc-switch-speedtest/1.0")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||||
|
let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||||
|
secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_timeout_clamps_values() {
|
||||||
|
assert_eq!(
|
||||||
|
SpeedtestService::sanitize_timeout(Some(1)),
|
||||||
|
MIN_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SpeedtestService::sanitize_timeout(Some(999)),
|
||||||
|
MAX_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SpeedtestService::sanitize_timeout(Some(10)),
|
||||||
|
10.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SpeedtestService::sanitize_timeout(None),
|
||||||
|
DEFAULT_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_endpoints_handles_empty_list() {
|
||||||
|
let result =
|
||||||
|
tauri::async_runtime::block_on(SpeedtestService::test_endpoints(Vec::new(), Some(5)))
|
||||||
|
.expect("empty list should succeed");
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_endpoints_reports_invalid_url() {
|
||||||
|
let result = tauri::async_runtime::block_on(SpeedtestService::test_endpoints(
|
||||||
|
vec!["not a url".into(), "".into()],
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
.expect("invalid inputs should still succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert!(
|
||||||
|
result[0]
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.starts_with("URL 无效"),
|
||||||
|
"invalid url should yield parse error"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result[1].error.as_deref(),
|
||||||
|
Some("URL 不能为空"),
|
||||||
|
"empty url should report validation error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ use std::fs;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{OnceLock, RwLock};
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// 自定义端点配置
|
/// 自定义端点配置
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -117,18 +119,18 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self) -> Result<(), String> {
|
pub fn save(&self) -> Result<(), AppError> {
|
||||||
let mut normalized = self.clone();
|
let mut normalized = self.clone();
|
||||||
normalized.normalize_paths();
|
normalized.normalize_paths();
|
||||||
let path = Self::settings_path();
|
let path = Self::settings_path();
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?;
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&normalized)
|
let json = serde_json::to_string_pretty(&normalized)
|
||||||
.map_err(|e| format!("序列化设置失败: {}", e))?;
|
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
fs::write(&path, json).map_err(|e| format!("写入设置失败: {}", e))?;
|
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +162,7 @@ pub fn get_settings() -> AppSettings {
|
|||||||
settings_store().read().expect("读取设置锁失败").clone()
|
settings_store().read().expect("读取设置锁失败").clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), String> {
|
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||||
new_settings.normalize_paths();
|
new_settings.normalize_paths();
|
||||||
new_settings.save()?;
|
new_settings.save()?;
|
||||||
|
|
||||||
@@ -183,4 +185,4 @@ pub fn get_codex_override_dir() -> Option<PathBuf> {
|
|||||||
.codex_config_dir
|
.codex_config_dir
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| resolve_override_path(p))
|
.map(|p| resolve_override_path(p))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
use futures::future::join_all;
|
|
||||||
use reqwest::{Client, Url};
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 8;
|
|
||||||
const MAX_TIMEOUT_SECS: u64 = 30;
|
|
||||||
const MIN_TIMEOUT_SECS: u64 = 2;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct EndpointLatency {
|
|
||||||
pub url: String,
|
|
||||||
pub latency: Option<u128>,
|
|
||||||
pub status: Option<u16>,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_client(timeout_secs: u64) -> Result<Client, String> {
|
|
||||||
Client::builder()
|
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
|
||||||
.redirect(reqwest::redirect::Policy::limited(5))
|
|
||||||
.user_agent("cc-switch-speedtest/1.0")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
|
||||||
let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
|
||||||
secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn test_endpoints(
|
|
||||||
urls: Vec<String>,
|
|
||||||
timeout_secs: Option<u64>,
|
|
||||||
) -> Result<Vec<EndpointLatency>, String> {
|
|
||||||
if urls.is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout = sanitize_timeout(timeout_secs);
|
|
||||||
let client = build_client(timeout)?;
|
|
||||||
|
|
||||||
let tasks = urls.into_iter().map(|raw_url| {
|
|
||||||
let client = client.clone();
|
|
||||||
async move {
|
|
||||||
let trimmed = raw_url.trim().to_string();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return EndpointLatency {
|
|
||||||
url: raw_url,
|
|
||||||
latency: None,
|
|
||||||
status: None,
|
|
||||||
error: Some("URL 不能为空".to_string()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed_url = match Url::parse(&trimmed) {
|
|
||||||
Ok(url) => url,
|
|
||||||
Err(err) => {
|
|
||||||
return EndpointLatency {
|
|
||||||
url: trimmed,
|
|
||||||
latency: None,
|
|
||||||
status: None,
|
|
||||||
error: Some(format!("URL 无效: {err}")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 先进行一次“热身”请求,忽略其结果,仅用于复用连接/绕过首包惩罚
|
|
||||||
let _ = client.get(parsed_url.clone()).send().await;
|
|
||||||
|
|
||||||
// 第二次请求开始计时,并将其作为结果返回
|
|
||||||
let start = Instant::now();
|
|
||||||
match client.get(parsed_url).send().await {
|
|
||||||
Ok(resp) => {
|
|
||||||
let latency = start.elapsed().as_millis();
|
|
||||||
EndpointLatency {
|
|
||||||
url: trimmed,
|
|
||||||
latency: Some(latency),
|
|
||||||
status: Some(resp.status().as_u16()),
|
|
||||||
error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
let status = err.status().map(|s| s.as_u16());
|
|
||||||
let error_message = if err.is_timeout() {
|
|
||||||
"请求超时".to_string()
|
|
||||||
} else if err.is_connect() {
|
|
||||||
"连接失败".to_string()
|
|
||||||
} else {
|
|
||||||
err.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
EndpointLatency {
|
|
||||||
url: trimmed,
|
|
||||||
latency: None,
|
|
||||||
status,
|
|
||||||
error: Some(error_message),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let results = join_all(tasks).await;
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
use crate::app_config::MultiAppConfig;
|
use crate::app_config::MultiAppConfig;
|
||||||
use std::sync::Mutex;
|
use crate::error::AppError;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
/// 全局应用状态
|
/// 全局应用状态
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<MultiAppConfig>,
|
pub config: RwLock<MultiAppConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -15,16 +22,13 @@ impl AppState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config: Mutex::new(config),
|
config: RwLock::new(config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置到文件
|
/// 保存配置到文件
|
||||||
pub fn save(&self) -> Result<(), String> {
|
pub fn save(&self) -> Result<(), AppError> {
|
||||||
let config = self
|
let config = self.config.read().map_err(AppError::from)?;
|
||||||
.config
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
|
||||||
|
|
||||||
config.save()
|
config.save()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use rquickjs::{Context, Runtime, Function};
|
use rquickjs::{Context, Function, Runtime};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// 执行用量查询脚本
|
/// 执行用量查询脚本
|
||||||
pub async fn execute_usage_script(
|
pub async fn execute_usage_script(
|
||||||
script_code: &str,
|
script_code: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, AppError> {
|
||||||
// 1. 替换变量
|
// 1. 替换变量
|
||||||
let replaced = script_code
|
let replaced = script_code
|
||||||
.replace("{{apiKey}}", api_key)
|
.replace("{{apiKey}}", api_key)
|
||||||
@@ -18,75 +20,80 @@ pub async fn execute_usage_script(
|
|||||||
|
|
||||||
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||||
let request_config = {
|
let request_config = {
|
||||||
let runtime = Runtime::new().map_err(|e| format!("创建 JS 运行时失败: {}", e))?;
|
let runtime =
|
||||||
let context = Context::full(&runtime).map_err(|e| format!("创建 JS 上下文失败: {}", e))?;
|
Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?;
|
||||||
|
let context = Context::full(&runtime)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?;
|
||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 执行用户代码,获取配置对象
|
// 执行用户代码,获取配置对象
|
||||||
let config: rquickjs::Object = ctx
|
let config: rquickjs::Object = ctx
|
||||||
.eval(replaced.clone())
|
.eval(replaced.clone())
|
||||||
.map_err(|e| format!("解析配置失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("解析配置失败: {}", e)))?;
|
||||||
|
|
||||||
// 提取 request 配置
|
// 提取 request 配置
|
||||||
let request: rquickjs::Object = config
|
let request: rquickjs::Object = config
|
||||||
.get("request")
|
.get("request")
|
||||||
.map_err(|e| format!("缺少 request 配置: {}", e))?;
|
.map_err(|e| AppError::Message(format!("缺少 request 配置: {}", e)))?;
|
||||||
|
|
||||||
// 将 request 转换为 JSON 字符串
|
// 将 request 转换为 JSON 字符串
|
||||||
let request_json: String = ctx
|
let request_json: String = ctx
|
||||||
.json_stringify(request)
|
.json_stringify(request)
|
||||||
.map_err(|e| format!("序列化 request 失败: {}", e))?
|
.map_err(|e| AppError::Message(format!("序列化 request 失败: {}", e)))?
|
||||||
.ok_or("序列化返回 None")?
|
.ok_or_else(|| AppError::Message("序列化返回 None".into()))?
|
||||||
.get()
|
.get()
|
||||||
.map_err(|e| format!("获取字符串失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("获取字符串失败: {}", e)))?;
|
||||||
|
|
||||||
Ok::<_, String>(request_json)
|
Ok::<_, AppError>(request_json)
|
||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
// 3. 解析 request 配置
|
// 3. 解析 request 配置
|
||||||
let request: RequestConfig = serde_json::from_str(&request_config)
|
let request: RequestConfig = serde_json::from_str(&request_config)
|
||||||
.map_err(|e| format!("request 配置格式错误: {}", e))?;
|
.map_err(|e| AppError::Message(format!("request 配置格式错误: {}", e)))?;
|
||||||
|
|
||||||
// 4. 发送 HTTP 请求
|
// 4. 发送 HTTP 请求
|
||||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||||
|
|
||||||
// 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
// 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
||||||
let result: Value = {
|
let result: Value = {
|
||||||
let runtime = Runtime::new().map_err(|e| format!("创建 JS 运行时失败: {}", e))?;
|
let runtime =
|
||||||
let context = Context::full(&runtime).map_err(|e| format!("创建 JS 上下文失败: {}", e))?;
|
Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?;
|
||||||
|
let context = Context::full(&runtime)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?;
|
||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 重新 eval 获取配置对象
|
// 重新 eval 获取配置对象
|
||||||
let config: rquickjs::Object = ctx
|
let config: rquickjs::Object = ctx
|
||||||
.eval(replaced.clone())
|
.eval(replaced.clone())
|
||||||
.map_err(|e| format!("重新解析配置失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("重新解析配置失败: {}", e)))?;
|
||||||
|
|
||||||
// 提取 extractor 函数
|
// 提取 extractor 函数
|
||||||
let extractor: Function = config
|
let extractor: Function = config
|
||||||
.get("extractor")
|
.get("extractor")
|
||||||
.map_err(|e| format!("缺少 extractor 函数: {}", e))?;
|
.map_err(|e| AppError::Message(format!("缺少 extractor 函数: {}", e)))?;
|
||||||
|
|
||||||
// 将响应数据转换为 JS 值
|
// 将响应数据转换为 JS 值
|
||||||
let response_js: rquickjs::Value = ctx
|
let response_js: rquickjs::Value = ctx
|
||||||
.json_parse(response_data.as_str())
|
.json_parse(response_data.as_str())
|
||||||
.map_err(|e| format!("解析响应 JSON 失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("解析响应 JSON 失败: {}", e)))?;
|
||||||
|
|
||||||
// 调用 extractor(response)
|
// 调用 extractor(response)
|
||||||
let result_js: rquickjs::Value = extractor
|
let result_js: rquickjs::Value = extractor
|
||||||
.call((response_js,))
|
.call((response_js,))
|
||||||
.map_err(|e| format!("执行 extractor 失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("执行 extractor 失败: {}", e)))?;
|
||||||
|
|
||||||
// 转换为 JSON 字符串
|
// 转换为 JSON 字符串
|
||||||
let result_json: String = ctx
|
let result_json: String = ctx
|
||||||
.json_stringify(result_js)
|
.json_stringify(result_js)
|
||||||
.map_err(|e| format!("序列化结果失败: {}", e))?
|
.map_err(|e| AppError::Message(format!("序列化结果失败: {}", e)))?
|
||||||
.ok_or("序列化返回 None")?
|
.ok_or_else(|| AppError::Message("序列化返回 None".into()))?
|
||||||
.get()
|
.get()
|
||||||
.map_err(|e| format!("获取字符串失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("获取字符串失败: {}", e)))?;
|
||||||
|
|
||||||
// 解析为 serde_json::Value
|
// 解析为 serde_json::Value
|
||||||
serde_json::from_str(&result_json).map_err(|e| format!("JSON 解析失败: {}", e))
|
serde_json::from_str(&result_json)
|
||||||
|
.map_err(|e| AppError::Message(format!("JSON 解析失败: {}", e)))
|
||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
@@ -108,16 +115,13 @@ struct RequestConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 发送 HTTP 请求
|
/// 发送 HTTP 请求
|
||||||
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, String> {
|
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("创建客户端失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("创建客户端失败: {}", e)))?;
|
||||||
|
|
||||||
let method = config
|
let method = config.method.parse().unwrap_or(reqwest::Method::GET);
|
||||||
.method
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(reqwest::Method::GET);
|
|
||||||
|
|
||||||
let mut req = client.request(method.clone(), &config.url);
|
let mut req = client.request(method.clone(), &config.url);
|
||||||
|
|
||||||
@@ -135,13 +139,13 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
|||||||
let resp = req
|
let resp = req
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("请求失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("请求失败: {}", e)))?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let text = resp
|
let text = resp
|
||||||
.text()
|
.text()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("读取响应失败: {}", e))?;
|
.map_err(|e| AppError::Message(format!("读取响应失败: {}", e)))?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let preview = if text.len() > 200 {
|
let preview = if text.len() > 200 {
|
||||||
@@ -149,22 +153,22 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
|||||||
} else {
|
} else {
|
||||||
text.clone()
|
text.clone()
|
||||||
};
|
};
|
||||||
return Err(format!("HTTP {} : {}", status, preview));
|
return Err(AppError::Message(format!("HTTP {} : {}", status, preview)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(text)
|
Ok(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 验证脚本返回值(支持单对象或数组)
|
/// 验证脚本返回值(支持单对象或数组)
|
||||||
fn validate_result(result: &Value) -> Result<(), String> {
|
fn validate_result(result: &Value) -> Result<(), AppError> {
|
||||||
// 如果是数组,验证每个元素
|
// 如果是数组,验证每个元素
|
||||||
if let Some(arr) = result.as_array() {
|
if let Some(arr) = result.as_array() {
|
||||||
if arr.is_empty() {
|
if arr.is_empty() {
|
||||||
return Err("脚本返回的数组不能为空".to_string());
|
return Err(AppError::InvalidInput("脚本返回的数组不能为空".into()));
|
||||||
}
|
}
|
||||||
for (idx, item) in arr.iter().enumerate() {
|
for (idx, item) in arr.iter().enumerate() {
|
||||||
validate_single_usage(item)
|
validate_single_usage(item)
|
||||||
.map_err(|e| format!("数组索引[{}]验证失败: {}", idx, e))?;
|
.map_err(|e| AppError::InvalidInput(format!("数组索引[{}]验证失败: {}", idx, e)))?;
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -174,33 +178,51 @@ fn validate_result(result: &Value) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 验证单个用量数据对象
|
/// 验证单个用量数据对象
|
||||||
fn validate_single_usage(result: &Value) -> Result<(), String> {
|
fn validate_single_usage(result: &Value) -> Result<(), AppError> {
|
||||||
let obj = result.as_object().ok_or("脚本必须返回对象或对象数组")?;
|
let obj = result
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("脚本必须返回对象或对象数组".into()))?;
|
||||||
|
|
||||||
// 所有字段均为可选,只进行类型检查
|
// 所有字段均为可选,只进行类型检查
|
||||||
if obj.contains_key("isValid") && !result["isValid"].is_null() && !result["isValid"].is_boolean() {
|
if obj.contains_key("isValid")
|
||||||
return Err("isValid 必须是布尔值或 null".to_string());
|
&& !result["isValid"].is_null()
|
||||||
|
&& !result["isValid"].is_boolean()
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput("isValid 必须是布尔值或 null".into()));
|
||||||
}
|
}
|
||||||
if obj.contains_key("invalidMessage") && !result["invalidMessage"].is_null() && !result["invalidMessage"].is_string() {
|
if obj.contains_key("invalidMessage")
|
||||||
return Err("invalidMessage 必须是字符串或 null".to_string());
|
&& !result["invalidMessage"].is_null()
|
||||||
|
&& !result["invalidMessage"].is_string()
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"invalidMessage 必须是字符串或 null".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if obj.contains_key("remaining") && !result["remaining"].is_null() && !result["remaining"].is_number() {
|
if obj.contains_key("remaining")
|
||||||
return Err("remaining 必须是数字或 null".to_string());
|
&& !result["remaining"].is_null()
|
||||||
|
&& !result["remaining"].is_number()
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput("remaining 必须是数字或 null".into()));
|
||||||
}
|
}
|
||||||
if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() {
|
if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() {
|
||||||
return Err("unit 必须是字符串或 null".to_string());
|
return Err(AppError::InvalidInput("unit 必须是字符串或 null".into()));
|
||||||
}
|
}
|
||||||
if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() {
|
if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() {
|
||||||
return Err("total 必须是数字或 null".to_string());
|
return Err(AppError::InvalidInput("total 必须是数字或 null".into()));
|
||||||
}
|
}
|
||||||
if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() {
|
if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() {
|
||||||
return Err("used 必须是数字或 null".to_string());
|
return Err(AppError::InvalidInput("used 必须是数字或 null".into()));
|
||||||
}
|
}
|
||||||
if obj.contains_key("planName") && !result["planName"].is_null() && !result["planName"].is_string() {
|
if obj.contains_key("planName")
|
||||||
return Err("planName 必须是字符串或 null".to_string());
|
&& !result["planName"].is_null()
|
||||||
|
&& !result["planName"].is_string()
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"planName 必须是字符串或 null".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() {
|
if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() {
|
||||||
return Err("extra 必须是字符串或 null".to_string());
|
return Err(AppError::InvalidInput("extra 必须是字符串或 null".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
22
src-tauri/tests/app_type_parse.rs
Normal file
22
src-tauri/tests/app_type_parse.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use cc_switch_lib::AppType;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_known_apps_case_insensitive_and_trim() {
|
||||||
|
assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude)));
|
||||||
|
assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex)));
|
||||||
|
assert!(matches!(
|
||||||
|
AppType::from_str(" ClAuDe \n"),
|
||||||
|
Ok(AppType::Claude)
|
||||||
|
));
|
||||||
|
assert!(matches!(AppType::from_str("\tcoDeX\t"), Ok(AppType::Codex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_app_returns_localized_error_message() {
|
||||||
|
let err = AppType::from_str("unknown").unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("可选值") || msg.contains("Allowed"));
|
||||||
|
assert!(msg.contains("unknown"));
|
||||||
|
}
|
||||||
960
src-tauri/tests/import_export_sync.rs
Normal file
960
src-tauri/tests/import_export_sync.rs
Normal file
@@ -0,0 +1,960 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
use std::{fs, path::Path, sync::RwLock};
|
||||||
|
use tauri::async_runtime;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService,
|
||||||
|
MultiAppConfig, Provider,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_claude_provider_writes_live_settings() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
let provider_config = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.test"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"displayName": "Test Provider"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"prov-1".to_string(),
|
||||||
|
"Test Claude".to_string(),
|
||||||
|
provider_config.clone(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.providers.insert("prov-1".to_string(), provider);
|
||||||
|
manager.current = "prov-1".to_string();
|
||||||
|
|
||||||
|
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
|
||||||
|
|
||||||
|
let settings_path = get_claude_settings_path();
|
||||||
|
assert!(
|
||||||
|
settings_path.exists(),
|
||||||
|
"live settings should be written to {}",
|
||||||
|
settings_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
|
||||||
|
assert_eq!(live_value, provider_config);
|
||||||
|
|
||||||
|
// 确认 SSOT 中的供应商也同步了最新内容
|
||||||
|
let updated = config
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.and_then(|m| m.providers.get("prov-1"))
|
||||||
|
.expect("provider in config");
|
||||||
|
assert_eq!(updated.settings_config, provider_config);
|
||||||
|
|
||||||
|
// 额外确认写入位置位于测试 HOME 下
|
||||||
|
assert!(
|
||||||
|
settings_path.starts_with(home),
|
||||||
|
"settings path {:?} should reside under test HOME {:?}",
|
||||||
|
settings_path,
|
||||||
|
home
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_codex_provider_writes_auth_and_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
|
||||||
|
// 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"echo-server".into(),
|
||||||
|
json!({
|
||||||
|
"id": "echo-server",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo",
|
||||||
|
"args": ["hello"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider_config = json!({
|
||||||
|
"auth": {
|
||||||
|
"OPENAI_API_KEY": "codex-key"
|
||||||
|
},
|
||||||
|
"config": r#"base_url = "https://codex.test""#
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"codex-1".to_string(),
|
||||||
|
"Codex Test".to_string(),
|
||||||
|
provider_config.clone(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.providers.insert("codex-1".to_string(), provider);
|
||||||
|
manager.current = "codex-1".to_string();
|
||||||
|
|
||||||
|
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
|
||||||
|
|
||||||
|
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||||||
|
let config_path = cc_switch_lib::get_codex_config_path();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
auth_path.exists(),
|
||||||
|
"auth.json should exist at {}",
|
||||||
|
auth_path.display()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"config.toml should exist at {}",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
|
||||||
|
assert_eq!(
|
||||||
|
auth_value,
|
||||||
|
provider_config.get("auth").cloned().expect("auth object")
|
||||||
|
);
|
||||||
|
|
||||||
|
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
toml_text.contains("command = \"echo\""),
|
||||||
|
"config.toml should contain serialized enabled MCP server"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当前供应商应同步最新 config 文本
|
||||||
|
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
|
||||||
|
let synced = manager.providers.get("codex-1").expect("codex provider");
|
||||||
|
let synced_cfg = synced
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.expect("config string");
|
||||||
|
assert_eq!(synced_cfg, toml_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_enabled_to_codex_writes_enabled_servers() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"stdio-enabled".into(),
|
||||||
|
json!({
|
||||||
|
"id": "stdio-enabled",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo",
|
||||||
|
"args": ["ok"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||||||
|
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
assert!(path.exists(), "config.toml should be created");
|
||||||
|
let text = fs::read_to_string(&path).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
text.contains("mcp_servers") && text.contains("stdio-enabled"),
|
||||||
|
"enabled servers should be serialized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_enabled_to_codex_preserves_non_mcp_content_and_style() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
// 预置含有顶层注释与非 MCP 键的 config.toml
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
let seed = r#"# top-comment
|
||||||
|
title = "keep-me"
|
||||||
|
|
||||||
|
[profile]
|
||||||
|
mode = "dev"
|
||||||
|
"#;
|
||||||
|
fs::write(&path, seed).expect("seed config.toml");
|
||||||
|
|
||||||
|
// 启用一个 MCP 项,触发增量写入
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"echo".into(),
|
||||||
|
json!({
|
||||||
|
"id": "echo",
|
||||||
|
"enabled": true,
|
||||||
|
"server": { "type": "stdio", "command": "echo" }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||||||
|
|
||||||
|
let text = fs::read_to_string(&path).expect("read config.toml");
|
||||||
|
// 顶层注释与非 MCP 键应保留
|
||||||
|
assert!(
|
||||||
|
text.contains("# top-comment"),
|
||||||
|
"top comment should be preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("title = \"keep-me\""),
|
||||||
|
"top key should be preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("[profile]"),
|
||||||
|
"non-MCP table should be preserved"
|
||||||
|
);
|
||||||
|
// 新增的 mcp_servers/或 mcp.servers 应存在并包含 echo
|
||||||
|
assert!(
|
||||||
|
text.contains("mcp_servers") || text.contains("[mcp.servers]"),
|
||||||
|
"one server table style should be present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("echo") && text.contains("command = \"echo\""),
|
||||||
|
"echo server should be serialized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
// 预置 mcp.servers 风格
|
||||||
|
let seed = r#"[mcp]
|
||||||
|
other = "keep"
|
||||||
|
[mcp.servers]
|
||||||
|
"#;
|
||||||
|
fs::write(&path, seed).expect("seed config.toml");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"echo".into(),
|
||||||
|
json!({
|
||||||
|
"id": "echo",
|
||||||
|
"enabled": true,
|
||||||
|
"server": { "type": "stdio", "command": "echo" }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||||||
|
let text = fs::read_to_string(&path).expect("read config.toml");
|
||||||
|
// 仍应采用 mcp.servers 风格
|
||||||
|
assert!(
|
||||||
|
text.contains("[mcp.servers]"),
|
||||||
|
"should keep mcp.servers style"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("mcp_servers"),
|
||||||
|
"should not switch to mcp_servers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_enabled_to_codex_removes_servers_when_none_enabled() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
r#"[mcp_servers]
|
||||||
|
disabled = { type = "stdio", command = "noop" }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("seed config file");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::default(); // 无启用项
|
||||||
|
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||||||
|
|
||||||
|
let text = fs::read_to_string(&path).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
!text.contains("mcp_servers") && !text.contains("servers"),
|
||||||
|
"disabled entries should be removed from config.toml"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
fs::write(&path, "invalid = [").expect("write invalid config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"broken".into(),
|
||||||
|
json!({
|
||||||
|
"id": "broken",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = cc_switch_lib::sync_enabled_to_codex(&config).expect_err("sync should fail");
|
||||||
|
match err {
|
||||||
|
cc_switch_lib::AppError::Toml { path, .. } => {
|
||||||
|
assert!(
|
||||||
|
path.ends_with("config.toml"),
|
||||||
|
"path should reference config.toml"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cc_switch_lib::AppError::McpValidation(msg) => {
|
||||||
|
assert!(
|
||||||
|
msg.contains("config.toml"),
|
||||||
|
"error message should mention config.toml"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_codex_provider_missing_auth_returns_error() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"codex-missing-auth".to_string(),
|
||||||
|
"No Auth".to_string(),
|
||||||
|
json!({
|
||||||
|
"config": "model = \"test\""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.providers.insert(provider.id.clone(), provider);
|
||||||
|
manager.current = "codex-missing-auth".to_string();
|
||||||
|
|
||||||
|
let err = ConfigService::sync_current_providers_to_live(&mut config)
|
||||||
|
.expect_err("sync should fail when auth missing");
|
||||||
|
match err {
|
||||||
|
cc_switch_lib::AppError::Config(msg) => {
|
||||||
|
assert!(msg.contains("auth"), "error message should mention auth");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error variant: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认未产生任何 live 配置文件
|
||||||
|
assert!(
|
||||||
|
!cc_switch_lib::get_codex_auth_path().exists(),
|
||||||
|
"auth.json should not be created on failure"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!cc_switch_lib::get_codex_config_path().exists(),
|
||||||
|
"config.toml should not be created on failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_codex_live_atomic_persists_auth_and_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
let auth = json!({ "OPENAI_API_KEY": "dev-key" });
|
||||||
|
let config_text = r#"
|
||||||
|
[mcp_servers.echo]
|
||||||
|
type = "stdio"
|
||||||
|
command = "echo"
|
||||||
|
args = ["ok"]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text))
|
||||||
|
.expect("atomic write should succeed");
|
||||||
|
|
||||||
|
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||||||
|
let config_path = cc_switch_lib::get_codex_config_path();
|
||||||
|
assert!(auth_path.exists(), "auth.json should be created");
|
||||||
|
assert!(config_path.exists(), "config.toml should be created");
|
||||||
|
|
||||||
|
let stored_auth: serde_json::Value =
|
||||||
|
cc_switch_lib::read_json_file(&auth_path).expect("read auth");
|
||||||
|
assert_eq!(stored_auth, auth, "auth.json should match input");
|
||||||
|
|
||||||
|
let stored_config = std::fs::read_to_string(&config_path).expect("read config");
|
||||||
|
assert!(
|
||||||
|
stored_config.contains("mcp_servers.echo"),
|
||||||
|
"config.toml should contain serialized table"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_codex_live_atomic_rolls_back_auth_when_config_write_fails() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||||||
|
if let Some(parent) = auth_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
std::fs::write(&auth_path, r#"{"OPENAI_API_KEY":"legacy"}"#).expect("seed auth");
|
||||||
|
|
||||||
|
let config_path = cc_switch_lib::get_codex_config_path();
|
||||||
|
std::fs::create_dir_all(&config_path).expect("create blocking directory");
|
||||||
|
|
||||||
|
let auth = json!({ "OPENAI_API_KEY": "new-key" });
|
||||||
|
let config_text = r#"[mcp_servers.sample]
|
||||||
|
type = "stdio"
|
||||||
|
command = "noop"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text))
|
||||||
|
.expect_err("config write should fail when target is directory");
|
||||||
|
match err {
|
||||||
|
cc_switch_lib::AppError::Io { path, .. } => {
|
||||||
|
assert!(
|
||||||
|
path.ends_with("config.toml"),
|
||||||
|
"io error path should point to config.toml"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cc_switch_lib::AppError::IoContext { context, .. } => {
|
||||||
|
assert!(
|
||||||
|
context.contains("config.toml"),
|
||||||
|
"error context should mention config path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error variant: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored = std::fs::read_to_string(&auth_path).expect("read existing auth");
|
||||||
|
assert!(
|
||||||
|
stored.contains("legacy"),
|
||||||
|
"auth.json should roll back to legacy content"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
std::fs::metadata(&config_path)
|
||||||
|
.expect("config path metadata")
|
||||||
|
.is_dir(),
|
||||||
|
"config path should remain a directory after failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_from_codex_adds_servers_from_mcp_servers_table() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
r#"[mcp_servers.echo_server]
|
||||||
|
type = "stdio"
|
||||||
|
command = "echo"
|
||||||
|
args = ["hello"]
|
||||||
|
|
||||||
|
[mcp_servers.http_server]
|
||||||
|
type = "http"
|
||||||
|
url = "https://example.com"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("write codex config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
||||||
|
assert!(changed >= 2, "should import both servers");
|
||||||
|
|
||||||
|
let servers = &config.mcp.codex.servers;
|
||||||
|
let echo = servers
|
||||||
|
.get("echo_server")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("echo server");
|
||||||
|
assert_eq!(echo.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
||||||
|
let server_spec = echo
|
||||||
|
.get("server")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("server spec");
|
||||||
|
assert_eq!(
|
||||||
|
server_spec
|
||||||
|
.get("command")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(""),
|
||||||
|
"echo"
|
||||||
|
);
|
||||||
|
|
||||||
|
let http = servers
|
||||||
|
.get("http_server")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("http server");
|
||||||
|
let http_spec = http
|
||||||
|
.get("server")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("http spec");
|
||||||
|
assert_eq!(
|
||||||
|
http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"https://example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_from_codex_merges_into_existing_entries() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
r#"[mcp.servers.existing]
|
||||||
|
type = "stdio"
|
||||||
|
command = "echo"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("write codex config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"existing".into(),
|
||||||
|
json!({
|
||||||
|
"id": "existing",
|
||||||
|
"name": "existing",
|
||||||
|
"enabled": false,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "prev"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
||||||
|
assert!(changed >= 1, "should mark change for enabled flag");
|
||||||
|
|
||||||
|
let entry = config
|
||||||
|
.mcp
|
||||||
|
.codex
|
||||||
|
.servers
|
||||||
|
.get("existing")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("existing entry");
|
||||||
|
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
||||||
|
let spec = entry
|
||||||
|
.get("server")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("server spec");
|
||||||
|
// 保留原 command,确保导入不会覆盖现有 server 细节
|
||||||
|
assert_eq!(spec.get("command").and_then(|v| v.as_str()), Some("prev"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_claude_enabled_mcp_projects_to_user_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
|
||||||
|
config.mcp.claude.servers.insert(
|
||||||
|
"stdio-enabled".into(),
|
||||||
|
json!({
|
||||||
|
"id": "stdio-enabled",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo",
|
||||||
|
"args": ["hi"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
config.mcp.claude.servers.insert(
|
||||||
|
"http-disabled".into(),
|
||||||
|
json!({
|
||||||
|
"id": "http-disabled",
|
||||||
|
"enabled": false,
|
||||||
|
"server": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://example.com",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cc_switch_lib::sync_enabled_to_claude(&config).expect("sync Claude MCP");
|
||||||
|
|
||||||
|
let claude_path = cc_switch_lib::get_claude_mcp_path();
|
||||||
|
assert!(claude_path.exists(), "claude config should exist");
|
||||||
|
let text = fs::read_to_string(&claude_path).expect("read .claude.json");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&text).expect("parse claude json");
|
||||||
|
let servers = value
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("mcpServers map");
|
||||||
|
assert_eq!(servers.len(), 1, "only enabled entries should be written");
|
||||||
|
let enabled = servers.get("stdio-enabled").expect("enabled entry");
|
||||||
|
assert_eq!(
|
||||||
|
enabled
|
||||||
|
.get("command")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
"echo"
|
||||||
|
);
|
||||||
|
assert!(servers.get("http-disabled").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_from_claude_merges_into_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
let claude_path = home.join(".claude.json");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&claude_path,
|
||||||
|
serde_json::to_string_pretty(&json!({
|
||||||
|
"mcpServers": {
|
||||||
|
"stdio-enabled": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo",
|
||||||
|
"args": ["hello"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.expect("write claude json");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.mcp.claude.servers.insert(
|
||||||
|
"stdio-enabled".into(),
|
||||||
|
json!({
|
||||||
|
"id": "stdio-enabled",
|
||||||
|
"name": "stdio-enabled",
|
||||||
|
"enabled": false,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "prev"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let changed = cc_switch_lib::import_from_claude(&mut config).expect("import from claude");
|
||||||
|
assert!(changed >= 1, "should mark at least one change");
|
||||||
|
|
||||||
|
let entry = config
|
||||||
|
.mcp
|
||||||
|
.claude
|
||||||
|
.servers
|
||||||
|
.get("stdio-enabled")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("entry exists");
|
||||||
|
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
||||||
|
let server = entry
|
||||||
|
.get("server")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("server obj");
|
||||||
|
assert_eq!(
|
||||||
|
server.get("command").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"prev",
|
||||||
|
"existing server config should be preserved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_backup_skips_missing_file() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
|
||||||
|
// 未创建文件时应返回空字符串,不报错
|
||||||
|
let result = ConfigService::create_backup(&config_path).expect("create backup");
|
||||||
|
assert!(
|
||||||
|
result.is_empty(),
|
||||||
|
"expected empty backup id when config file missing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_backup_generates_snapshot_file() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
let config_dir = home.join(".cc-switch");
|
||||||
|
let config_path = config_dir.join("config.json");
|
||||||
|
fs::create_dir_all(&config_dir).expect("prepare config dir");
|
||||||
|
fs::write(&config_path, r#"{"version":2}"#).expect("write config file");
|
||||||
|
|
||||||
|
let backup_id = ConfigService::create_backup(&config_path).expect("backup success");
|
||||||
|
assert!(
|
||||||
|
!backup_id.is_empty(),
|
||||||
|
"backup id should contain timestamp information"
|
||||||
|
);
|
||||||
|
|
||||||
|
let backup_path = config_dir.join("backups").join(format!("{backup_id}.json"));
|
||||||
|
assert!(
|
||||||
|
backup_path.exists(),
|
||||||
|
"expected backup file at {}",
|
||||||
|
backup_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let backup_content = fs::read_to_string(&backup_path).expect("read backup");
|
||||||
|
assert!(
|
||||||
|
backup_content.contains(r#""version":2"#),
|
||||||
|
"backup content should match original config"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_backup_retains_only_latest_entries() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
let config_dir = home.join(".cc-switch");
|
||||||
|
let config_path = config_dir.join("config.json");
|
||||||
|
fs::create_dir_all(&config_dir).expect("prepare config dir");
|
||||||
|
fs::write(&config_path, r#"{"version":3}"#).expect("write config file");
|
||||||
|
|
||||||
|
let backups_dir = config_dir.join("backups");
|
||||||
|
fs::create_dir_all(&backups_dir).expect("create backups dir");
|
||||||
|
for idx in 0..12 {
|
||||||
|
let manual = backups_dir.join(format!("manual_{idx:02}.json"));
|
||||||
|
fs::write(&manual, format!("{{\"idx\":{idx}}}")).expect("seed manual backup");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
|
||||||
|
let latest_backup_id =
|
||||||
|
ConfigService::create_backup(&config_path).expect("create backup with cleanup");
|
||||||
|
assert!(
|
||||||
|
!latest_backup_id.is_empty(),
|
||||||
|
"backup id should not be empty when config exists"
|
||||||
|
);
|
||||||
|
|
||||||
|
let entries: Vec<_> = fs::read_dir(&backups_dir)
|
||||||
|
.expect("read backups dir")
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
entries.len() <= 10,
|
||||||
|
"expected backups to be trimmed to at most 10 files, got {}",
|
||||||
|
entries.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let latest_path = backups_dir.join(format!("{latest_backup_id}.json"));
|
||||||
|
assert!(
|
||||||
|
latest_path.exists(),
|
||||||
|
"latest backup {} should be preserved",
|
||||||
|
latest_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 进一步确认保留的条目包含一些历史文件,说明清理逻辑仅裁剪多余部分
|
||||||
|
let manual_kept = entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| entry.file_name().into_string().ok())
|
||||||
|
.any(|name| name.starts_with("manual_"));
|
||||||
|
assert!(
|
||||||
|
manual_kept,
|
||||||
|
"cleanup should keep part of the older backups to maintain history"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_config_from_path_overwrites_state_and_creates_backup() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let config_dir = home.join(".cc-switch");
|
||||||
|
fs::create_dir_all(&config_dir).expect("create config dir");
|
||||||
|
let config_path = config_dir.join("config.json");
|
||||||
|
fs::write(&config_path, r#"{"version":1}"#).expect("seed original config");
|
||||||
|
|
||||||
|
let import_payload = serde_json::json!({
|
||||||
|
"version": 2,
|
||||||
|
"claude": {
|
||||||
|
"providers": {
|
||||||
|
"p-new": {
|
||||||
|
"id": "p-new",
|
||||||
|
"name": "Test Claude",
|
||||||
|
"settingsConfig": {
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "new-key" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"current": "p-new"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"providers": {},
|
||||||
|
"current": ""
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"claude": { "servers": {} },
|
||||||
|
"codex": { "servers": {} }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let import_path = config_dir.join("import.json");
|
||||||
|
fs::write(
|
||||||
|
&import_path,
|
||||||
|
serde_json::to_string_pretty(&import_payload).expect("serialize import payload"),
|
||||||
|
)
|
||||||
|
.expect("write import file");
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let backup_id = ConfigService::import_config_from_path(&import_path, &app_state)
|
||||||
|
.expect("import should succeed");
|
||||||
|
assert!(
|
||||||
|
!backup_id.is_empty(),
|
||||||
|
"expected backup id when original config exists"
|
||||||
|
);
|
||||||
|
|
||||||
|
let backup_path = config_dir.join("backups").join(format!("{backup_id}.json"));
|
||||||
|
assert!(
|
||||||
|
backup_path.exists(),
|
||||||
|
"backup file should exist at {}",
|
||||||
|
backup_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let updated_content = fs::read_to_string(&config_path).expect("read updated config");
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(&updated_content).expect("parse updated config");
|
||||||
|
assert_eq!(
|
||||||
|
parsed
|
||||||
|
.get("claude")
|
||||||
|
.and_then(|c| c.get("current"))
|
||||||
|
.and_then(|c| c.as_str()),
|
||||||
|
Some("p-new"),
|
||||||
|
"saved config should record new current provider"
|
||||||
|
);
|
||||||
|
|
||||||
|
let guard = app_state.config.read().expect("lock state after import");
|
||||||
|
let claude_manager = guard
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager in state");
|
||||||
|
assert_eq!(
|
||||||
|
claude_manager.current, "p-new",
|
||||||
|
"state should reflect new current provider"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
claude_manager.providers.contains_key("p-new"),
|
||||||
|
"new provider should exist in state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_config_from_path_invalid_json_returns_error() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let config_dir = home.join(".cc-switch");
|
||||||
|
fs::create_dir_all(&config_dir).expect("create config dir");
|
||||||
|
|
||||||
|
let invalid_path = config_dir.join("broken.json");
|
||||||
|
fs::write(&invalid_path, "{ not-json ").expect("write invalid json");
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = ConfigService::import_config_from_path(&invalid_path, &app_state)
|
||||||
|
.expect_err("import should fail");
|
||||||
|
match err {
|
||||||
|
AppError::Json { .. } => {}
|
||||||
|
other => panic!("expected json error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_config_from_path_missing_file_produces_io_error() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let missing_path = Path::new("/nonexistent/import.json");
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = ConfigService::import_config_from_path(missing_path, &app_state)
|
||||||
|
.expect_err("import should fail for missing file");
|
||||||
|
match err {
|
||||||
|
AppError::Io { .. } => {}
|
||||||
|
other => panic!("expected io error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_config_to_file_writes_target_path() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let config_dir = home.join(".cc-switch");
|
||||||
|
fs::create_dir_all(&config_dir).expect("create config dir");
|
||||||
|
let config_path = config_dir.join("config.json");
|
||||||
|
fs::write(&config_path, r#"{"version":42,"flag":true}"#).expect("write config");
|
||||||
|
|
||||||
|
let export_path = home.join("exported-config.json");
|
||||||
|
if export_path.exists() {
|
||||||
|
fs::remove_file(&export_path).expect("cleanup export target");
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = async_runtime::block_on(cc_switch_lib::export_config_to_file(
|
||||||
|
export_path.to_string_lossy().to_string(),
|
||||||
|
))
|
||||||
|
.expect("export should succeed");
|
||||||
|
assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(true));
|
||||||
|
|
||||||
|
let exported = fs::read_to_string(&export_path).expect("read exported file");
|
||||||
|
assert!(
|
||||||
|
exported.contains(r#""version":42"#) && exported.contains(r#""flag":true"#),
|
||||||
|
"exported file should mirror source config content"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_config_to_file_returns_error_when_source_missing() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let export_path = home.join("export-missing.json");
|
||||||
|
if export_path.exists() {
|
||||||
|
fs::remove_file(&export_path).expect("cleanup export target");
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = async_runtime::block_on(cc_switch_lib::export_config_to_file(
|
||||||
|
export_path.to_string_lossy().to_string(),
|
||||||
|
))
|
||||||
|
.expect_err("export should fail when config.json missing");
|
||||||
|
assert!(
|
||||||
|
err.contains("IO 错误"),
|
||||||
|
"expected IO error message, got {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
234
src-tauri/tests/mcp_commands.rs
Normal file
234
src-tauri/tests/mcp_commands.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
use std::{fs, sync::RwLock};
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
|
||||||
|
AppState, AppType, McpService, MultiAppConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_default_config_claude_persists_provider() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let settings_path = get_claude_settings_path();
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create claude settings dir");
|
||||||
|
}
|
||||||
|
let settings = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.test"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
&settings_path,
|
||||||
|
serde_json::to_string_pretty(&settings).expect("serialize settings"),
|
||||||
|
)
|
||||||
|
.expect("seed claude settings.json");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.ensure_app(&AppType::Claude);
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
import_default_config_test_hook(&state, AppType::Claude)
|
||||||
|
.expect("import default config succeeds");
|
||||||
|
|
||||||
|
// 验证内存状态
|
||||||
|
let guard = state.config.read().expect("lock config");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager present");
|
||||||
|
assert_eq!(manager.current, "default");
|
||||||
|
let default_provider = manager.providers.get("default").expect("default provider");
|
||||||
|
assert_eq!(
|
||||||
|
default_provider.settings_config, settings,
|
||||||
|
"default provider should capture live settings"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
// 验证配置已持久化
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"importing default config should persist config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_default_config_without_live_file_returns_error() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = import_default_config_test_hook(&state, AppType::Claude)
|
||||||
|
.expect_err("missing live file should error");
|
||||||
|
match err {
|
||||||
|
AppError::Localized { zh, .. } => assert!(
|
||||||
|
zh.contains("Claude Code 配置文件不存在"),
|
||||||
|
"unexpected error message: {zh}"
|
||||||
|
),
|
||||||
|
AppError::Message(msg) => assert!(
|
||||||
|
msg.contains("Claude Code 配置文件不存在"),
|
||||||
|
"unexpected error message: {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("unexpected error variant: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
!config_path.exists(),
|
||||||
|
"failed import should not create config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_mcp_from_claude_creates_config_and_enables_servers() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mcp_path = get_claude_mcp_path();
|
||||||
|
let claude_json = json!({
|
||||||
|
"mcpServers": {
|
||||||
|
"echo": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
&mcp_path,
|
||||||
|
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
|
||||||
|
)
|
||||||
|
.expect("seed ~/.claude.json");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds");
|
||||||
|
assert!(
|
||||||
|
changed > 0,
|
||||||
|
"import should report inserted or normalized entries"
|
||||||
|
);
|
||||||
|
|
||||||
|
let guard = state.config.read().expect("lock config");
|
||||||
|
let claude_servers = &guard.mcp.claude.servers;
|
||||||
|
let entry = claude_servers
|
||||||
|
.get("echo")
|
||||||
|
.expect("server imported into config.json");
|
||||||
|
assert!(
|
||||||
|
entry
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false),
|
||||||
|
"imported server should be marked enabled"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"state.save should persist config.json when changes detected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_mcp_from_claude_invalid_json_preserves_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mcp_path = get_claude_mcp_path();
|
||||||
|
fs::write(&mcp_path, "{\"mcpServers\":") // 不完整 JSON
|
||||||
|
.expect("seed invalid ~/.claude.json");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err =
|
||||||
|
McpService::import_from_claude(&state).expect_err("invalid json should bubble up error");
|
||||||
|
match err {
|
||||||
|
AppError::McpValidation(msg) => assert!(
|
||||||
|
msg.contains("解析 ~/.claude.json 失败"),
|
||||||
|
"unexpected error message: {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("unexpected error variant: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
!config_path.exists(),
|
||||||
|
"failed import should not persist config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.ensure_app(&AppType::Codex);
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"codex-server".into(),
|
||||||
|
json!({
|
||||||
|
"id": "codex-server",
|
||||||
|
"name": "Codex Server",
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
},
|
||||||
|
"enabled": false
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
McpService::set_enabled(&state, AppType::Codex, "codex-server", true)
|
||||||
|
.expect("set enabled should succeed");
|
||||||
|
|
||||||
|
let guard = state.config.read().expect("lock config");
|
||||||
|
let entry = guard
|
||||||
|
.mcp
|
||||||
|
.codex
|
||||||
|
.servers
|
||||||
|
.get("codex-server")
|
||||||
|
.expect("codex server exists");
|
||||||
|
assert!(
|
||||||
|
entry
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false),
|
||||||
|
"server should be marked enabled after command"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let toml_path = cc_switch_lib::get_codex_config_path();
|
||||||
|
assert!(
|
||||||
|
toml_path.exists(),
|
||||||
|
"enabling server should trigger sync to ~/.codex/config.toml"
|
||||||
|
);
|
||||||
|
let toml_text = fs::read_to_string(&toml_path).expect("read codex config");
|
||||||
|
assert!(
|
||||||
|
toml_text.contains("codex-server"),
|
||||||
|
"codex config should include the enabled server definition"
|
||||||
|
);
|
||||||
|
}
|
||||||
327
src-tauri/tests/provider_commands.rs
Normal file
327
src-tauri/tests/provider_commands.rs
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook,
|
||||||
|
write_codex_live_atomic, AppError, AppState, AppType, MultiAppConfig, Provider,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_updates_codex_live_and_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let legacy_auth = json!({"OPENAI_API_KEY": "legacy-key"});
|
||||||
|
let legacy_config = r#"[mcp_servers.legacy]
|
||||||
|
type = "stdio"
|
||||||
|
command = "echo"
|
||||||
|
"#;
|
||||||
|
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
|
||||||
|
.expect("seed existing codex live config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.current = "old-provider".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
"Legacy".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "stale"},
|
||||||
|
"config": "stale-config"
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
"Latest".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "fresh-key"},
|
||||||
|
"config": r#"[mcp_servers.latest]
|
||||||
|
type = "stdio"
|
||||||
|
command = "say"
|
||||||
|
"#
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"echo-server".into(),
|
||||||
|
json!({
|
||||||
|
"id": "echo-server",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch_provider_test_hook(&app_state, AppType::Codex, "new-provider")
|
||||||
|
.expect("switch provider should succeed");
|
||||||
|
|
||||||
|
let auth_value: serde_json::Value =
|
||||||
|
read_json_file(&get_codex_auth_path()).expect("read auth.json");
|
||||||
|
assert_eq!(
|
||||||
|
auth_value
|
||||||
|
.get("OPENAI_API_KEY")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(""),
|
||||||
|
"fresh-key",
|
||||||
|
"live auth.json should reflect new provider"
|
||||||
|
);
|
||||||
|
|
||||||
|
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
config_text.contains("mcp_servers.echo-server"),
|
||||||
|
"config.toml should contain synced MCP servers"
|
||||||
|
);
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after switch");
|
||||||
|
let manager = locked
|
||||||
|
.get_manager(&AppType::Codex)
|
||||||
|
.expect("codex manager after switch");
|
||||||
|
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||||
|
|
||||||
|
let new_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("new-provider")
|
||||||
|
.expect("new provider exists");
|
||||||
|
let new_config_text = new_provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(
|
||||||
|
new_config_text, config_text,
|
||||||
|
"provider config snapshot should match live file"
|
||||||
|
);
|
||||||
|
|
||||||
|
let legacy = manager
|
||||||
|
.providers
|
||||||
|
.get("old-provider")
|
||||||
|
.expect("legacy provider still exists");
|
||||||
|
let legacy_auth_value = legacy
|
||||||
|
.settings_config
|
||||||
|
.get("auth")
|
||||||
|
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_auth_value, "legacy-key",
|
||||||
|
"previous provider should be backfilled with live auth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_missing_provider_returns_error() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager")
|
||||||
|
.current = "does-not-exist".to_string();
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider")
|
||||||
|
.expect_err("switching to a missing provider should fail");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("供应商不存在"),
|
||||||
|
"error message should mention missing provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_updates_claude_live_and_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let settings_path = cc_switch_lib::get_claude_settings_path();
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||||
|
}
|
||||||
|
let legacy_live = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "legacy-key"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"path": "/tmp/workspace"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::fs::write(
|
||||||
|
&settings_path,
|
||||||
|
serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"),
|
||||||
|
)
|
||||||
|
.expect("seed claude live config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.current = "old-provider".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
"Legacy Claude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "stale-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
"Fresh Claude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "fresh-key" },
|
||||||
|
"workspace": { "path": "/tmp/new-workspace" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch_provider_test_hook(&app_state, AppType::Claude, "new-provider")
|
||||||
|
.expect("switch provider should succeed");
|
||||||
|
|
||||||
|
let live_after: serde_json::Value =
|
||||||
|
read_json_file(&settings_path).expect("read claude live settings");
|
||||||
|
assert_eq!(
|
||||||
|
live_after
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("fresh-key"),
|
||||||
|
"live settings.json should reflect new provider auth"
|
||||||
|
);
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after switch");
|
||||||
|
let manager = locked
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager after switch");
|
||||||
|
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||||
|
|
||||||
|
let legacy_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("old-provider")
|
||||||
|
.expect("legacy provider still exists");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_provider.settings_config, legacy_live,
|
||||||
|
"previous provider should receive backfilled live config"
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("new-provider")
|
||||||
|
.expect("new provider exists");
|
||||||
|
assert_eq!(
|
||||||
|
new_provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("fresh-key"),
|
||||||
|
"new provider snapshot should retain fresh auth"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(locked);
|
||||||
|
|
||||||
|
let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
|
||||||
|
let config_path = std::path::Path::new(&home_dir)
|
||||||
|
.join(".cc-switch")
|
||||||
|
.join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"switching provider should persist config.json"
|
||||||
|
);
|
||||||
|
let persisted: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&config_path).expect("read saved config"))
|
||||||
|
.expect("parse saved config");
|
||||||
|
assert_eq!(
|
||||||
|
persisted
|
||||||
|
.get("claude")
|
||||||
|
.and_then(|claude| claude.get("current"))
|
||||||
|
.and_then(|current| current.as_str()),
|
||||||
|
Some("new-provider"),
|
||||||
|
"saved config.json should record the new current provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.providers.insert(
|
||||||
|
"invalid".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"invalid".to_string(),
|
||||||
|
"Broken Codex".to_string(),
|
||||||
|
json!({
|
||||||
|
"config": "[mcp_servers.test]\ncommand = \"noop\""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid")
|
||||||
|
.expect_err("switching should fail when auth missing");
|
||||||
|
match err {
|
||||||
|
AppError::Config(msg) => assert!(
|
||||||
|
msg.contains("auth"),
|
||||||
|
"expected auth missing error message, got {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("expected config error, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after failure");
|
||||||
|
let manager = locked.get_manager(&AppType::Codex).expect("codex manager");
|
||||||
|
assert!(
|
||||||
|
manager.current.is_empty(),
|
||||||
|
"current provider should remain empty on failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
450
src-tauri/tests/provider_service.rs
Normal file
450
src-tauri/tests/provider_service.rs
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType,
|
||||||
|
MultiAppConfig, Provider, ProviderService,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
fn sanitize_provider_name(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_switch_codex_updates_live_and_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let legacy_auth = json!({ "OPENAI_API_KEY": "legacy-key" });
|
||||||
|
let legacy_config = r#"[mcp_servers.legacy]
|
||||||
|
type = "stdio"
|
||||||
|
command = "echo"
|
||||||
|
"#;
|
||||||
|
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
|
||||||
|
.expect("seed existing codex live config");
|
||||||
|
|
||||||
|
let mut initial_config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = initial_config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.current = "old-provider".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
"Legacy".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "stale"},
|
||||||
|
"config": "stale-config"
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
"Latest".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "fresh-key"},
|
||||||
|
"config": r#"[mcp_servers.latest]
|
||||||
|
type = "stdio"
|
||||||
|
command = "say"
|
||||||
|
"#
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
initial_config.mcp.codex.servers.insert(
|
||||||
|
"echo-server".into(),
|
||||||
|
json!({
|
||||||
|
"id": "echo-server",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(initial_config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Codex, "new-provider")
|
||||||
|
.expect("switch provider should succeed");
|
||||||
|
|
||||||
|
let auth_value: serde_json::Value =
|
||||||
|
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
|
||||||
|
assert_eq!(
|
||||||
|
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
|
||||||
|
Some("fresh-key"),
|
||||||
|
"live auth.json should reflect new provider"
|
||||||
|
);
|
||||||
|
|
||||||
|
let config_text =
|
||||||
|
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
config_text.contains("mcp_servers.echo-server"),
|
||||||
|
"config.toml should contain synced MCP servers"
|
||||||
|
);
|
||||||
|
|
||||||
|
let guard = state.config.read().expect("read config after switch");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Codex)
|
||||||
|
.expect("codex manager after switch");
|
||||||
|
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||||
|
|
||||||
|
let new_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("new-provider")
|
||||||
|
.expect("new provider exists");
|
||||||
|
let new_config_text = new_provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(
|
||||||
|
new_config_text, config_text,
|
||||||
|
"provider config snapshot should match live file"
|
||||||
|
);
|
||||||
|
|
||||||
|
let legacy = manager
|
||||||
|
.providers
|
||||||
|
.get("old-provider")
|
||||||
|
.expect("legacy provider still exists");
|
||||||
|
let legacy_auth_value = legacy
|
||||||
|
.settings_config
|
||||||
|
.get("auth")
|
||||||
|
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_auth_value, "legacy-key",
|
||||||
|
"previous provider should be backfilled with live auth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_switch_claude_updates_live_and_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let settings_path = get_claude_settings_path();
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||||
|
}
|
||||||
|
let legacy_live = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "legacy-key"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"path": "/tmp/workspace"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::fs::write(
|
||||||
|
&settings_path,
|
||||||
|
serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"),
|
||||||
|
)
|
||||||
|
.expect("seed claude live config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.current = "old-provider".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
"Legacy Claude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "stale-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
"Fresh Claude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "fresh-key" },
|
||||||
|
"workspace": { "path": "/tmp/new-workspace" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Claude, "new-provider")
|
||||||
|
.expect("switch provider should succeed");
|
||||||
|
|
||||||
|
let live_after: serde_json::Value =
|
||||||
|
read_json_file(&settings_path).expect("read claude live settings");
|
||||||
|
assert_eq!(
|
||||||
|
live_after
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("fresh-key"),
|
||||||
|
"live settings.json should reflect new provider auth"
|
||||||
|
);
|
||||||
|
|
||||||
|
let guard = state
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.expect("read claude config after switch");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager after switch");
|
||||||
|
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||||
|
|
||||||
|
let legacy_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("old-provider")
|
||||||
|
.expect("legacy provider still exists");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_provider.settings_config, legacy_live,
|
||||||
|
"previous provider should receive backfilled live config"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_switch_missing_provider_returns_error() {
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = ProviderService::switch(&state, AppType::Claude, "missing")
|
||||||
|
.expect_err("switching missing provider should fail");
|
||||||
|
match err {
|
||||||
|
AppError::ProviderNotFound(id) => assert_eq!(id, "missing"),
|
||||||
|
other => panic!("expected ProviderNotFound, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_switch_codex_missing_auth_returns_error() {
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.providers.insert(
|
||||||
|
"invalid".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"invalid".to_string(),
|
||||||
|
"Broken Codex".to_string(),
|
||||||
|
json!({
|
||||||
|
"config": "[mcp_servers.test]\ncommand = \"noop\""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = ProviderService::switch(&state, AppType::Codex, "invalid")
|
||||||
|
.expect_err("switching should fail without auth");
|
||||||
|
match err {
|
||||||
|
AppError::Config(msg) => assert!(
|
||||||
|
msg.contains("auth"),
|
||||||
|
"expected auth related message, got {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("expected config error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_delete_codex_removes_provider_and_files() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.current = "keep".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"keep".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"keep".to_string(),
|
||||||
|
"Keep".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "keep-key"},
|
||||||
|
"config": ""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"to-delete".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"to-delete".to_string(),
|
||||||
|
"DeleteCodex".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "delete-key"},
|
||||||
|
"config": ""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized = sanitize_provider_name("DeleteCodex");
|
||||||
|
let codex_dir = home.join(".codex");
|
||||||
|
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||||
|
let auth_path = codex_dir.join(format!("auth-{}.json", sanitized));
|
||||||
|
let cfg_path = codex_dir.join(format!("config-{}.toml", sanitized));
|
||||||
|
std::fs::write(&auth_path, "{}").expect("seed auth file");
|
||||||
|
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::delete(&app_state, AppType::Codex, "to-delete")
|
||||||
|
.expect("delete provider should succeed");
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after delete");
|
||||||
|
let manager = locked.get_manager(&AppType::Codex).expect("codex manager");
|
||||||
|
assert!(
|
||||||
|
!manager.providers.contains_key("to-delete"),
|
||||||
|
"provider entry should be removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!auth_path.exists() && !cfg_path.exists(),
|
||||||
|
"provider-specific files should be deleted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_delete_claude_removes_provider_files() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.current = "keep".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"keep".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"keep".to_string(),
|
||||||
|
"Keep".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "keep-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"delete".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"delete".to_string(),
|
||||||
|
"DeleteClaude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "delete-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized = sanitize_provider_name("DeleteClaude");
|
||||||
|
let claude_dir = home.join(".claude");
|
||||||
|
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
|
||||||
|
let by_name = claude_dir.join(format!("settings-{}.json", sanitized));
|
||||||
|
let by_id = claude_dir.join("settings-delete.json");
|
||||||
|
std::fs::write(&by_name, "{}").expect("seed settings by name");
|
||||||
|
std::fs::write(&by_id, "{}").expect("seed settings by id");
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after delete");
|
||||||
|
let manager = locked
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
assert!(
|
||||||
|
!manager.providers.contains_key("delete"),
|
||||||
|
"claude provider should be removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!by_name.exists() && !by_id.exists(),
|
||||||
|
"provider config files should be deleted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_delete_current_provider_returns_error() {
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.current = "keep".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"keep".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"keep".to_string(),
|
||||||
|
"Keep".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "keep-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = ProviderService::delete(&app_state, AppType::Claude, "keep")
|
||||||
|
.expect_err("deleting current provider should fail");
|
||||||
|
match err {
|
||||||
|
AppError::Localized { zh, .. } => assert!(
|
||||||
|
zh.contains("不能删除当前正在使用的供应商"),
|
||||||
|
"unexpected message: {zh}"
|
||||||
|
),
|
||||||
|
AppError::Config(msg) => assert!(
|
||||||
|
msg.contains("不能删除当前正在使用的供应商"),
|
||||||
|
"unexpected message: {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("expected Config error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src-tauri/tests/support.rs
Normal file
47
src-tauri/tests/support.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use cc_switch_lib::{update_settings, AppSettings};
|
||||||
|
|
||||||
|
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
||||||
|
pub fn ensure_test_home() -> &'static Path {
|
||||||
|
static HOME: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
HOME.get_or_init(|| {
|
||||||
|
let base = std::env::temp_dir().join("cc-switch-test-home");
|
||||||
|
if base.exists() {
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(&base).expect("create test home");
|
||||||
|
std::env::set_var("HOME", &base);
|
||||||
|
#[cfg(windows)]
|
||||||
|
std::env::set_var("USERPROFILE", &base);
|
||||||
|
base
|
||||||
|
})
|
||||||
|
.as_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理测试目录中生成的配置文件与缓存。
|
||||||
|
pub fn reset_test_fs() {
|
||||||
|
let home = ensure_test_home();
|
||||||
|
for sub in [".claude", ".codex", ".cc-switch"] {
|
||||||
|
let path = home.join(sub);
|
||||||
|
if path.exists() {
|
||||||
|
if let Err(err) = std::fs::remove_dir_all(&path) {
|
||||||
|
eprintln!("failed to clean {}: {}", path.display(), err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let claude_json = home.join(".claude.json");
|
||||||
|
if claude_json.exists() {
|
||||||
|
let _ = std::fs::remove_file(&claude_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置内存中的设置缓存,确保测试环境不受上一次调用影响
|
||||||
|
let _ = update_settings(AppSettings::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全局互斥锁,避免多测试并发写入相同的 HOME 目录。
|
||||||
|
pub fn test_mutex() -> &'static Mutex<()> {
|
||||||
|
static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
MUTEX.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
558
src/App.tsx
558
src/App.tsx
@@ -1,409 +1,297 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Provider } from "./types";
|
import { toast } from "sonner";
|
||||||
import { AppType } from "./lib/tauri-api";
|
import { Plus, Settings, Edit3 } from "lucide-react";
|
||||||
import ProviderList from "./components/ProviderList";
|
import type { Provider } from "@/types";
|
||||||
import AddProviderModal from "./components/AddProviderModal";
|
import { useProvidersQuery } from "@/lib/query";
|
||||||
import EditProviderModal from "./components/EditProviderModal";
|
import {
|
||||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
providersApi,
|
||||||
import { AppSwitcher } from "./components/AppSwitcher";
|
settingsApi,
|
||||||
import SettingsModal from "./components/SettingsModal";
|
type AppId,
|
||||||
import { UpdateBadge } from "./components/UpdateBadge";
|
type ProviderSwitchEvent,
|
||||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
} from "@/lib/api";
|
||||||
import McpPanel from "./components/mcp/McpPanel";
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
import { buttonStyles } from "./lib/styles";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { useDarkMode } from "./hooks/useDarkMode";
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
import { extractErrorMessage } from "./utils/errorUtils";
|
import { ProviderList } from "@/components/providers/ProviderList";
|
||||||
|
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
||||||
|
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
import { SettingsDialog } from "@/components/settings/SettingsDialog";
|
||||||
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
||||||
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||||
|
import McpPanel from "@/components/mcp/McpPanel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
|
||||||
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
const [activeApp, setActiveApp] = useState<AppId>("claude");
|
||||||
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
||||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [notification, setNotification] = useState<{
|
|
||||||
message: string;
|
|
||||||
type: "success" | "error";
|
|
||||||
} | null>(null);
|
|
||||||
const [isNotificationVisible, setIsNotificationVisible] = useState(false);
|
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
|
||||||
isOpen: boolean;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
onConfirm: () => void;
|
|
||||||
} | null>(null);
|
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||||
|
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
||||||
|
|
||||||
// 设置通知的辅助函数
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
||||||
const showNotification = (
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
message: string,
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
type: "success" | "error",
|
|
||||||
duration = 3000,
|
|
||||||
) => {
|
|
||||||
// 清除之前的定时器
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 立即显示通知
|
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
||||||
setNotification({ message, type });
|
const {
|
||||||
setIsNotificationVisible(true);
|
addProvider,
|
||||||
|
updateProvider,
|
||||||
|
switchProvider,
|
||||||
|
deleteProvider,
|
||||||
|
saveUsageScript,
|
||||||
|
} = useProviderActions(activeApp);
|
||||||
|
|
||||||
// 设置淡出定时器
|
// 监听来自托盘菜单的切换事件
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
setIsNotificationVisible(false);
|
|
||||||
// 等待淡出动画完成后清除通知
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotification(null);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}, 300); // 与CSS动画时间匹配
|
|
||||||
}, duration);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载供应商列表
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProviders();
|
let unsubscribe: (() => void) | undefined;
|
||||||
}, [activeApp]); // 当切换应用时重新加载
|
|
||||||
|
|
||||||
// 清理定时器
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 监听托盘切换事件(包括菜单切换)
|
|
||||||
useEffect(() => {
|
|
||||||
let unlisten: (() => void) | null = null;
|
|
||||||
|
|
||||||
const setupListener = async () => {
|
const setupListener = async () => {
|
||||||
try {
|
try {
|
||||||
unlisten = await window.api.onProviderSwitched(async (data) => {
|
unsubscribe = await providersApi.onSwitched(
|
||||||
if (import.meta.env.DEV) {
|
async (event: ProviderSwitchEvent) => {
|
||||||
console.log(t("console.providerSwitchReceived"), data);
|
if (event.appType === activeApp) {
|
||||||
}
|
await refetch();
|
||||||
|
}
|
||||||
// 如果当前应用类型匹配,则重新加载数据
|
},
|
||||||
if (data.appType === activeApp) {
|
);
|
||||||
await loadProviders();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 若为 Claude,则同步插件配置
|
|
||||||
if (data.appType === "claude") {
|
|
||||||
await syncClaudePlugin(data.providerId, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t("console.setupListenerFailed"), error);
|
console.error("[App] Failed to subscribe provider switch event", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setupListener();
|
setupListener();
|
||||||
|
|
||||||
// 清理监听器
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unlisten) {
|
unsubscribe?.();
|
||||||
unlisten();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [activeApp]);
|
}, [activeApp, refetch]);
|
||||||
|
|
||||||
const loadProviders = async () => {
|
// 打开网站链接
|
||||||
const loadedProviders = await window.api.getProviders(activeApp);
|
const handleOpenWebsite = async (url: string) => {
|
||||||
const currentId = await window.api.getCurrentProvider(activeApp);
|
try {
|
||||||
setProviders(loadedProviders);
|
await settingsApi.openExternal(url);
|
||||||
setCurrentProviderId(currentId);
|
} catch (error) {
|
||||||
|
const detail =
|
||||||
// 如果供应商列表为空,尝试自动从 live 导入一条默认供应商
|
extractErrorMessage(error) ||
|
||||||
if (Object.keys(loadedProviders).length === 0) {
|
t("notifications.openLinkFailed", {
|
||||||
await handleAutoImportDefault();
|
defaultValue: "链接打开失败",
|
||||||
|
});
|
||||||
|
toast.error(detail);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成唯一ID
|
// 编辑供应商
|
||||||
const generateId = () => {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddProvider = async (provider: Omit<Provider, "id">) => {
|
|
||||||
const newProvider: Provider = {
|
|
||||||
...provider,
|
|
||||||
id: generateId(),
|
|
||||||
createdAt: Date.now(), // 添加创建时间戳
|
|
||||||
};
|
|
||||||
await window.api.addProvider(newProvider, activeApp);
|
|
||||||
await loadProviders();
|
|
||||||
setIsAddModalOpen(false);
|
|
||||||
// 更新托盘菜单
|
|
||||||
await window.api.updateTrayMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditProvider = async (provider: Provider) => {
|
const handleEditProvider = async (provider: Provider) => {
|
||||||
try {
|
await updateProvider(provider);
|
||||||
await window.api.updateProvider(provider, activeApp);
|
setEditingProvider(null);
|
||||||
await loadProviders();
|
|
||||||
setEditingProviderId(null);
|
|
||||||
// 显示编辑成功提示
|
|
||||||
showNotification(t("notifications.providerSaved"), "success", 2000);
|
|
||||||
// 更新托盘菜单
|
|
||||||
await window.api.updateTrayMenu();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(t("console.updateProviderFailed"), error);
|
|
||||||
setEditingProviderId(null);
|
|
||||||
const errorMessage = extractErrorMessage(error);
|
|
||||||
const message = errorMessage
|
|
||||||
? t("notifications.saveFailed", { error: errorMessage })
|
|
||||||
: t("notifications.saveFailedGeneric");
|
|
||||||
showNotification(message, "error", errorMessage ? 6000 : 3000);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProvider = async (id: string) => {
|
// 确认删除供应商
|
||||||
const provider = providers[id];
|
const handleConfirmDelete = async () => {
|
||||||
setConfirmDialog({
|
if (!confirmDelete) return;
|
||||||
isOpen: true,
|
await deleteProvider(confirmDelete.id);
|
||||||
title: t("confirm.deleteProvider"),
|
setConfirmDelete(null);
|
||||||
message: t("confirm.deleteProviderMessage", { name: provider?.name }),
|
|
||||||
onConfirm: async () => {
|
|
||||||
await window.api.deleteProvider(id, activeApp);
|
|
||||||
await loadProviders();
|
|
||||||
setConfirmDialog(null);
|
|
||||||
showNotification(t("notifications.providerDeleted"), "success");
|
|
||||||
// 更新托盘菜单
|
|
||||||
await window.api.updateTrayMenu();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 同步 Claude 插件配置(按设置决定是否联动;开启时:非官方写入,官方移除)
|
// 复制供应商
|
||||||
const syncClaudePlugin = async (providerId: string, silent = false) => {
|
const handleDuplicateProvider = async (provider: Provider) => {
|
||||||
try {
|
// 1️⃣ 计算新的 sortIndex:如果原供应商有 sortIndex,则复制它
|
||||||
const settings = await window.api.getSettings();
|
const newSortIndex =
|
||||||
if (!(settings as any)?.enableClaudePluginIntegration) {
|
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
|
||||||
// 未开启联动:不执行写入/移除
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const provider = providers[providerId];
|
|
||||||
if (!provider) return;
|
|
||||||
const isOfficial = provider.category === "official";
|
|
||||||
await window.api.applyClaudePluginConfig({ official: isOfficial });
|
|
||||||
if (!silent) {
|
|
||||||
showNotification(
|
|
||||||
isOfficial
|
|
||||||
? t("notifications.removedFromClaudePlugin")
|
|
||||||
: t("notifications.appliedToClaudePlugin"),
|
|
||||||
"success",
|
|
||||||
2000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("同步 Claude 插件失败:", error);
|
|
||||||
if (!silent) {
|
|
||||||
const message =
|
|
||||||
error?.message || t("notifications.syncClaudePluginFailed");
|
|
||||||
showNotification(message, "error", 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSwitchProvider = async (id: string) => {
|
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> = {
|
||||||
try {
|
name: `${provider.name} copy`,
|
||||||
const success = await window.api.switchProvider(id, activeApp);
|
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
||||||
if (success) {
|
websiteUrl: provider.websiteUrl,
|
||||||
setCurrentProviderId(id);
|
category: provider.category,
|
||||||
// 显示重启提示
|
sortIndex: newSortIndex, // 复制原 sortIndex + 1
|
||||||
const appName = t(`apps.${activeApp}`);
|
meta: provider.meta
|
||||||
showNotification(
|
? JSON.parse(JSON.stringify(provider.meta))
|
||||||
t("notifications.switchSuccess", { appName }),
|
: undefined, // 深拷贝
|
||||||
"success",
|
};
|
||||||
2000,
|
|
||||||
);
|
|
||||||
// 更新托盘菜单
|
|
||||||
await window.api.updateTrayMenu();
|
|
||||||
|
|
||||||
if (activeApp === "claude") {
|
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
|
||||||
await syncClaudePlugin(id, true);
|
if (provider.sortIndex !== undefined) {
|
||||||
|
const updates = Object.values(providers)
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.sortIndex !== undefined &&
|
||||||
|
p.sortIndex >= newSortIndex! &&
|
||||||
|
p.id !== provider.id,
|
||||||
|
)
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
sortIndex: p.sortIndex! + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 先更新现有供应商的 sortIndex,为新供应商腾出位置
|
||||||
|
if (updates.length > 0) {
|
||||||
|
try {
|
||||||
|
await providersApi.updateSortOrder(updates, activeApp);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[App] Failed to update sort order", error);
|
||||||
|
toast.error(
|
||||||
|
t("provider.sortUpdateFailed", {
|
||||||
|
defaultValue: "排序更新失败",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return; // 如果排序更新失败,不继续添加
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
showNotification(t("notifications.switchFailed"), "error");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
const detail = extractErrorMessage(error);
|
|
||||||
const msg = detail
|
|
||||||
? `${t("notifications.switchFailed")}: ${detail}`
|
|
||||||
: t("notifications.switchFailed");
|
|
||||||
// 详细错误展示稍长时间,便于用户阅读
|
|
||||||
showNotification(msg, "error", detail ? 6000 : 3000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3️⃣ 添加复制的供应商
|
||||||
|
await addProvider(duplicatedProvider);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 导入配置成功后刷新
|
||||||
const handleImportSuccess = async () => {
|
const handleImportSuccess = async () => {
|
||||||
await loadProviders();
|
await refetch();
|
||||||
try {
|
try {
|
||||||
await window.api.updateTrayMenu();
|
await providersApi.updateTrayMenu();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[App] Failed to refresh tray menu after import", error);
|
console.error("[App] Failed to refresh tray menu", error);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 自动从 live 导入一条默认供应商(仅首次初始化时)
|
|
||||||
const handleAutoImportDefault = async () => {
|
|
||||||
try {
|
|
||||||
const result = await window.api.importCurrentConfigAsDefault(activeApp);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await loadProviders();
|
|
||||||
showNotification(t("notifications.autoImported"), "success", 3000);
|
|
||||||
// 更新托盘菜单
|
|
||||||
await window.api.updateTrayMenu();
|
|
||||||
}
|
|
||||||
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
|
|
||||||
} catch (error) {
|
|
||||||
console.error(t("console.autoImportFailed"), error);
|
|
||||||
// 静默处理,不影响用户体验
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
||||||
{/* 顶部导航区域 - 固定高度 */}
|
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
<header className="flex-shrink-0 bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/farion1231/cc-switch"
|
href="https://github.com/farion1231/cc-switch"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
title={t("header.viewOnGithub")}
|
|
||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</a>
|
</a>
|
||||||
<button
|
<Button
|
||||||
onClick={toggleDarkMode}
|
variant="ghost"
|
||||||
className={buttonStyles.icon}
|
size="icon"
|
||||||
title={
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
isDarkMode
|
title={t("common.settings")}
|
||||||
? t("header.toggleLightMode")
|
className="ml-2"
|
||||||
: t("header.toggleDarkMode")
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
title={t(
|
||||||
|
isEditMode ? "header.exitEditMode" : "header.enterEditMode",
|
||||||
|
)}
|
||||||
|
className={
|
||||||
|
isEditMode
|
||||||
|
? "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
: ""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
<Edit3 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
||||||
<button
|
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
|
||||||
className={buttonStyles.icon}
|
|
||||||
title={t("common.settings")}
|
|
||||||
>
|
|
||||||
<Settings size={18} />
|
|
||||||
</button>
|
|
||||||
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||||
|
<Button
|
||||||
<button
|
variant="mcp"
|
||||||
onClick={() => setIsMcpOpen(true)}
|
onClick={() => setIsMcpOpen(true)}
|
||||||
className="inline-flex items-center gap-2 px-7 py-2 text-sm font-medium rounded-lg transition-colors bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
className="min-w-[80px]"
|
||||||
>
|
>
|
||||||
MCP
|
MCP
|
||||||
</button>
|
</Button>
|
||||||
|
<Button onClick={() => setIsAddOpen(true)}>
|
||||||
<button
|
<Plus className="h-4 w-4" />
|
||||||
onClick={() => setIsAddModalOpen(true)}
|
|
||||||
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
{t("header.addProvider")}
|
{t("header.addProvider")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 主内容区域 - 独立滚动 */}
|
|
||||||
<main className="flex-1 overflow-y-scroll">
|
<main className="flex-1 overflow-y-scroll">
|
||||||
<div className="pt-3 px-6 pb-6">
|
<div className="mx-auto max-w-4xl px-6 py-6">
|
||||||
<div className="max-w-4xl mx-auto">
|
<ProviderList
|
||||||
{/* 通知组件 - 相对于视窗定位 */}
|
providers={providers}
|
||||||
{notification && (
|
currentProviderId={currentProviderId}
|
||||||
<div
|
appId={activeApp}
|
||||||
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-[80] px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
|
isLoading={isLoading}
|
||||||
notification.type === "error"
|
isEditMode={isEditMode}
|
||||||
? "bg-red-500 text-white"
|
onSwitch={switchProvider}
|
||||||
: "bg-green-500 text-white"
|
onEdit={setEditingProvider}
|
||||||
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
|
onDelete={setConfirmDelete}
|
||||||
>
|
onDuplicate={handleDuplicateProvider}
|
||||||
{notification.message}
|
onConfigureUsage={setUsageProvider}
|
||||||
</div>
|
onOpenWebsite={handleOpenWebsite}
|
||||||
)}
|
onCreate={() => setIsAddOpen(true)}
|
||||||
|
/>
|
||||||
<ProviderList
|
|
||||||
providers={providers}
|
|
||||||
currentProviderId={currentProviderId}
|
|
||||||
onSwitch={handleSwitchProvider}
|
|
||||||
onDelete={handleDeleteProvider}
|
|
||||||
onEdit={setEditingProviderId}
|
|
||||||
appType={activeApp}
|
|
||||||
onNotify={showNotification}
|
|
||||||
onProvidersUpdated={loadProviders}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{isAddModalOpen && (
|
<AddProviderDialog
|
||||||
<AddProviderModal
|
open={isAddOpen}
|
||||||
appType={activeApp}
|
onOpenChange={setIsAddOpen}
|
||||||
onAdd={handleAddProvider}
|
appId={activeApp}
|
||||||
onClose={() => setIsAddModalOpen(false)}
|
onSubmit={addProvider}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditProviderDialog
|
||||||
|
open={Boolean(editingProvider)}
|
||||||
|
provider={editingProvider}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingProvider(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSubmit={handleEditProvider}
|
||||||
|
appId={activeApp}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{usageProvider && (
|
||||||
|
<UsageScriptModal
|
||||||
|
provider={usageProvider}
|
||||||
|
appId={activeApp}
|
||||||
|
isOpen={Boolean(usageProvider)}
|
||||||
|
onClose={() => setUsageProvider(null)}
|
||||||
|
onSave={(script) => {
|
||||||
|
void saveUsageScript(usageProvider, script);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingProviderId && providers[editingProviderId] && (
|
<ConfirmDialog
|
||||||
<EditProviderModal
|
isOpen={Boolean(confirmDelete)}
|
||||||
appType={activeApp}
|
title={t("confirm.deleteProvider")}
|
||||||
provider={providers[editingProviderId]}
|
message={
|
||||||
onSave={handleEditProvider}
|
confirmDelete
|
||||||
onClose={() => setEditingProviderId(null)}
|
? t("confirm.deleteProviderMessage", {
|
||||||
/>
|
name: confirmDelete.name,
|
||||||
)}
|
})
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onConfirm={() => void handleConfirmDelete()}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{confirmDialog && (
|
<SettingsDialog
|
||||||
<ConfirmDialog
|
open={isSettingsOpen}
|
||||||
isOpen={confirmDialog.isOpen}
|
onOpenChange={setIsSettingsOpen}
|
||||||
title={confirmDialog.title}
|
onImportSuccess={handleImportSuccess}
|
||||||
message={confirmDialog.message}
|
/>
|
||||||
onConfirm={confirmDialog.onConfirm}
|
|
||||||
onCancel={() => setConfirmDialog(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSettingsOpen && (
|
<McpPanel
|
||||||
<SettingsModal
|
open={isMcpOpen}
|
||||||
onClose={() => setIsSettingsOpen(false)}
|
onOpenChange={setIsMcpOpen}
|
||||||
onImportSuccess={handleImportSuccess}
|
appId={activeApp}
|
||||||
onNotify={showNotification}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isMcpOpen && (
|
|
||||||
<McpPanel
|
|
||||||
appType={activeApp}
|
|
||||||
onClose={() => setIsMcpOpen(false)}
|
|
||||||
onNotify={showNotification}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Provider } from "../types";
|
|
||||||
import { AppType } from "../lib/tauri-api";
|
|
||||||
import ProviderForm from "./ProviderForm";
|
|
||||||
|
|
||||||
interface AddProviderModalProps {
|
|
||||||
appType: AppType;
|
|
||||||
onAdd: (provider: Omit<Provider, "id">) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddProviderModal: React.FC<AddProviderModalProps> = ({
|
|
||||||
appType,
|
|
||||||
onAdd,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const title =
|
|
||||||
appType === "claude"
|
|
||||||
? t("provider.addClaudeProvider")
|
|
||||||
: t("provider.addCodexProvider");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProviderForm
|
|
||||||
appType={appType}
|
|
||||||
title={title}
|
|
||||||
submitText={t("common.add")}
|
|
||||||
showPresets={true}
|
|
||||||
onSubmit={onAdd}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddProviderModal;
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { AppType } from "../lib/tauri-api";
|
import type { AppId } from "@/lib/api";
|
||||||
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
||||||
|
|
||||||
interface AppSwitcherProps {
|
interface AppSwitcherProps {
|
||||||
activeApp: AppType;
|
activeApp: AppId;
|
||||||
onSwitch: (app: AppType) => void;
|
onSwitch: (app: AppId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||||
const handleSwitch = (app: AppType) => {
|
const handleSwitch = (app: AppId) => {
|
||||||
if (app === activeApp) return;
|
if (app === activeApp) return;
|
||||||
onSwitch(app);
|
onSwitch(app);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent dark:border-gray-700">
|
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent ">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("claude")}
|
onClick={() => handleSwitch("claude")}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import React from "react";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, X } from "lucide-react";
|
|
||||||
import { isLinux } from "../lib/platform";
|
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,7 +20,7 @@ interface ConfirmDialogProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
export function ConfirmDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
@@ -21,63 +28,37 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|||||||
cancelText,
|
cancelText,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}) => {
|
}: ConfirmDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<Dialog
|
||||||
{/* Backdrop */}
|
open={isOpen}
|
||||||
<div
|
onOpenChange={(open) => {
|
||||||
className={`absolute inset-0 bg-black/50${isLinux() ? "" : " backdrop-blur-sm"}`}
|
if (!open) {
|
||||||
onClick={onCancel}
|
onCancel();
|
||||||
/>
|
}
|
||||||
|
}}
|
||||||
{/* Dialog */}
|
>
|
||||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-md w-full mx-4 overflow-hidden">
|
<DialogContent className="max-w-sm">
|
||||||
{/* Header */}
|
<DialogHeader className="space-y-3">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||||
<div className="flex items-center gap-3">
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/10 rounded-full flex items-center justify-center">
|
{title}
|
||||||
<AlertTriangle size={20} className="text-red-500" />
|
</DialogTitle>
|
||||||
</div>
|
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6">
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 leading-relaxed">
|
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</DialogDescription>
|
||||||
</div>
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
{/* Actions */}
|
<Button variant="outline" onClick={onCancel}>
|
||||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-900">
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
|
||||||
autoFocus
|
|
||||||
>
|
|
||||||
{cancelText || t("common.cancel")}
|
{cancelText || t("common.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
onClick={onConfirm}
|
|
||||||
className="px-4 py-2 text-sm font-medium bg-red-500 text-white hover:bg-red-500/90 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
{confirmText || t("common.confirm")}
|
{confirmText || t("common.confirm")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Provider } from "../types";
|
|
||||||
import { AppType } from "../lib/tauri-api";
|
|
||||||
import ProviderForm from "./ProviderForm";
|
|
||||||
|
|
||||||
interface EditProviderModalProps {
|
|
||||||
appType: AppType;
|
|
||||||
provider: Provider;
|
|
||||||
onSave: (provider: Provider) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
|
||||||
appType,
|
|
||||||
provider,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [effectiveProvider, setEffectiveProvider] =
|
|
||||||
useState<Provider>(provider);
|
|
||||||
|
|
||||||
// 若为当前应用且正在编辑“当前供应商”,则优先读取 live 配置作为初始值(Claude/Codex 均适用)
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
const maybeLoadLive = async () => {
|
|
||||||
try {
|
|
||||||
const currentId = await window.api.getCurrentProvider(appType);
|
|
||||||
if (currentId && currentId === provider.id) {
|
|
||||||
const live = await window.api.getLiveProviderSettings(appType);
|
|
||||||
if (!mounted) return;
|
|
||||||
setEffectiveProvider({ ...provider, settingsConfig: live });
|
|
||||||
} else {
|
|
||||||
setEffectiveProvider(provider);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 读取失败则回退到原 provider
|
|
||||||
setEffectiveProvider(provider);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
maybeLoadLive();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [appType, provider]);
|
|
||||||
|
|
||||||
const handleSubmit = (data: Omit<Provider, "id">) => {
|
|
||||||
onSave({
|
|
||||||
...provider,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const title =
|
|
||||||
appType === "claude"
|
|
||||||
? t("provider.editClaudeProvider")
|
|
||||||
: t("provider.editCodexProvider");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProviderForm
|
|
||||||
appType={appType}
|
|
||||||
title={title}
|
|
||||||
submitText={t("common.save")}
|
|
||||||
initialData={effectiveProvider}
|
|
||||||
showPresets={false}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditProviderModal;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface ImportProgressModalProps {
|
|
||||||
status: "importing" | "success" | "error";
|
|
||||||
message?: string;
|
|
||||||
backupId?: string;
|
|
||||||
onComplete?: () => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImportProgressModal({
|
|
||||||
status,
|
|
||||||
message,
|
|
||||||
backupId,
|
|
||||||
onComplete,
|
|
||||||
onSuccess,
|
|
||||||
}: ImportProgressModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "success") {
|
|
||||||
console.log(
|
|
||||||
"[ImportProgressModal] Success detected, starting 2 second countdown",
|
|
||||||
);
|
|
||||||
// 成功后等待2秒自动关闭并刷新数据
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
console.log(
|
|
||||||
"[ImportProgressModal] 2 seconds elapsed, calling callbacks...",
|
|
||||||
);
|
|
||||||
if (onSuccess) {
|
|
||||||
onSuccess();
|
|
||||||
}
|
|
||||||
if (onComplete) {
|
|
||||||
onComplete();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
console.log("[ImportProgressModal] Cleanup timer");
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [status, onComplete, onSuccess]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
|
||||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
|
||||||
|
|
||||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl p-8 max-w-md w-full mx-4">
|
|
||||||
<div className="flex flex-col items-center text-center">
|
|
||||||
{status === "importing" && (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
|
||||||
{t("settings.importing")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{t("common.loading")}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "success" && (
|
|
||||||
<>
|
|
||||||
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
|
||||||
{t("settings.importSuccess")}
|
|
||||||
</h3>
|
|
||||||
{backupId && (
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
{t("settings.backupId")}: {backupId}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{t("settings.autoReload")}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "error" && (
|
|
||||||
<>
|
|
||||||
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
|
||||||
{t("settings.importFailed")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{message || t("settings.configCorrupted")}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (onComplete) {
|
|
||||||
onComplete();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{t("common.close")}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -78,6 +78,20 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
|
|
||||||
// 创建编辑器扩展
|
// 创建编辑器扩展
|
||||||
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
|
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
|
||||||
|
|
||||||
|
// 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
"&light .cm-editor, &dark .cm-editor": {
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
},
|
||||||
|
"&light .cm-editor.cm-focused, &dark .cm-editor.cm-focused": {
|
||||||
|
outline: "none",
|
||||||
|
borderColor: "hsl(var(--primary))",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 theme 定义尺寸和字体样式
|
||||||
const sizingTheme = EditorView.theme({
|
const sizingTheme = EditorView.theme({
|
||||||
"&": height ? { height } : { minHeight: `${minHeightPx}px` },
|
"&": height ? { height } : { minHeight: `${minHeightPx}px` },
|
||||||
".cm-scroller": { overflow: "auto" },
|
".cm-scroller": { overflow: "auto" },
|
||||||
@@ -92,6 +106,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
basicSetup,
|
basicSetup,
|
||||||
language === "javascript" ? javascript() : json(),
|
language === "javascript" ? javascript() : json(),
|
||||||
placeholder(placeholderText || ""),
|
placeholder(placeholderText || ""),
|
||||||
|
baseTheme,
|
||||||
sizingTheme,
|
sizingTheme,
|
||||||
jsonLinter,
|
jsonLinter,
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
@@ -105,6 +120,19 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
// 如果启用深色模式,添加深色主题
|
// 如果启用深色模式,添加深色主题
|
||||||
if (darkMode) {
|
if (darkMode) {
|
||||||
extensions.push(oneDark);
|
extensions.push(oneDark);
|
||||||
|
// 在 oneDark 之后强制覆盖边框样式
|
||||||
|
extensions.push(
|
||||||
|
EditorView.theme({
|
||||||
|
".cm-editor": {
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
},
|
||||||
|
".cm-editor.cm-focused": {
|
||||||
|
outline: "none",
|
||||||
|
borderColor: "hsl(var(--primary))",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建初始状态
|
// 创建初始状态
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,205 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import JsonEditor from "../JsonEditor";
|
|
||||||
import { X, Save } from "lucide-react";
|
|
||||||
import { isLinux } from "../../lib/platform";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface ClaudeConfigEditorProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
commonConfigError: string;
|
|
||||||
configError: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
configError,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 检测暗色模式
|
|
||||||
const checkDarkMode = () => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
};
|
|
||||||
|
|
||||||
checkDarkMode();
|
|
||||||
|
|
||||||
// 监听暗色模式变化
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.attributeName === "class") {
|
|
||||||
checkDarkMode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
|
||||||
setIsCommonConfigModalOpen(true);
|
|
||||||
}
|
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
// 支持按下 ESC 关闭弹窗
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isCommonConfigModalOpen) return;
|
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setIsCommonConfigModalOpen(false);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label
|
|
||||||
htmlFor="settingsConfig"
|
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
{t("claudeConfig.configLabel")}
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
{t("claudeConfig.writeCommonConfig")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
{t("claudeConfig.editCommonConfig")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{commonConfigError && !isCommonConfigModalOpen && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<JsonEditor
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
placeholder={`{
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
rows={12}
|
|
||||||
/>
|
|
||||||
{configError && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("claudeConfig.fullSettingsHint")}
|
|
||||||
</p>
|
|
||||||
{isCommonConfigModalOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
if (e.target === e.currentTarget) closeModal();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Backdrop - 统一背景样式 */}
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
|
||||||
isLinux() ? "" : " backdrop-blur-sm"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal - 统一窗口样式 */}
|
|
||||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
{/* Header - 统一标题栏样式 */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{t("claudeConfig.editCommonConfigTitle")}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - 统一内容区域样式 */}
|
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("claudeConfig.commonConfigHint")}
|
|
||||||
</p>
|
|
||||||
<JsonEditor
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={12}
|
|
||||||
/>
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer - 统一底部按钮样式 */}
|
|
||||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ClaudeConfigEditor;
|
|
||||||
@@ -1,667 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { X, Save } from "lucide-react";
|
|
||||||
|
|
||||||
import { isLinux } from "../../lib/platform";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import {
|
|
||||||
generateThirdPartyAuth,
|
|
||||||
generateThirdPartyConfig,
|
|
||||||
} from "../../config/codexProviderPresets";
|
|
||||||
|
|
||||||
interface CodexConfigEditorProps {
|
|
||||||
authValue: string;
|
|
||||||
|
|
||||||
configValue: string;
|
|
||||||
|
|
||||||
onAuthChange: (value: string) => void;
|
|
||||||
|
|
||||||
onConfigChange: (value: string) => void;
|
|
||||||
|
|
||||||
onAuthBlur?: () => void;
|
|
||||||
|
|
||||||
useCommonConfig: boolean;
|
|
||||||
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
|
|
||||||
commonConfigError: string;
|
|
||||||
|
|
||||||
authError: string;
|
|
||||||
|
|
||||||
isCustomMode?: boolean; // 新增:是否为自定义模式
|
|
||||||
|
|
||||||
onWebsiteUrlChange?: (url: string) => void; // 新增:更新网址回调
|
|
||||||
|
|
||||||
isTemplateModalOpen?: boolean; // 新增:模态框状态
|
|
||||||
|
|
||||||
setIsTemplateModalOpen?: (open: boolean) => void; // 新增:设置模态框状态
|
|
||||||
|
|
||||||
onNameChange?: (name: string) => void; // 新增:更新供应商名称回调
|
|
||||||
}
|
|
||||||
|
|
||||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|
||||||
authValue,
|
|
||||||
|
|
||||||
configValue,
|
|
||||||
|
|
||||||
onAuthChange,
|
|
||||||
|
|
||||||
onConfigChange,
|
|
||||||
|
|
||||||
onAuthBlur,
|
|
||||||
|
|
||||||
useCommonConfig,
|
|
||||||
|
|
||||||
onCommonConfigToggle,
|
|
||||||
|
|
||||||
commonConfigSnippet,
|
|
||||||
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
|
|
||||||
commonConfigError,
|
|
||||||
|
|
||||||
authError,
|
|
||||||
|
|
||||||
onWebsiteUrlChange,
|
|
||||||
|
|
||||||
onNameChange,
|
|
||||||
|
|
||||||
isTemplateModalOpen: externalTemplateModalOpen,
|
|
||||||
|
|
||||||
setIsTemplateModalOpen: externalSetTemplateModalOpen,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// 使用内部状态或外部状态
|
|
||||||
|
|
||||||
const [internalTemplateModalOpen, setInternalTemplateModalOpen] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
const isTemplateModalOpen =
|
|
||||||
externalTemplateModalOpen ?? internalTemplateModalOpen;
|
|
||||||
|
|
||||||
const setIsTemplateModalOpen =
|
|
||||||
externalSetTemplateModalOpen ?? setInternalTemplateModalOpen;
|
|
||||||
|
|
||||||
const [templateApiKey, setTemplateApiKey] = useState("");
|
|
||||||
|
|
||||||
const [templateProviderName, setTemplateProviderName] = useState("");
|
|
||||||
|
|
||||||
const [templateBaseUrl, setTemplateBaseUrl] = useState("");
|
|
||||||
|
|
||||||
const [templateWebsiteUrl, setTemplateWebsiteUrl] = useState("");
|
|
||||||
|
|
||||||
const [templateModelName, setTemplateModelName] = useState("gpt-5-codex");
|
|
||||||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const baseUrlInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const modelNameInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const displayNameInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// 移除自动填充逻辑,因为现在在点击自定义按钮时就已经填充
|
|
||||||
|
|
||||||
const [templateDisplayName, setTemplateDisplayName] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
|
||||||
setIsCommonConfigModalOpen(true);
|
|
||||||
}
|
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
// 支持按下 ESC 关闭弹窗
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isCommonConfigModalOpen) return;
|
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setIsCommonConfigModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeTemplateModal = () => {
|
|
||||||
setIsTemplateModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyTemplate = () => {
|
|
||||||
const requiredInputs = [
|
|
||||||
displayNameInputRef.current,
|
|
||||||
apiKeyInputRef.current,
|
|
||||||
baseUrlInputRef.current,
|
|
||||||
modelNameInputRef.current,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const input of requiredInputs) {
|
|
||||||
if (input && !input.checkValidity()) {
|
|
||||||
input.reportValidity();
|
|
||||||
input.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedKey = templateApiKey.trim();
|
|
||||||
|
|
||||||
const trimmedBaseUrl = templateBaseUrl.trim();
|
|
||||||
|
|
||||||
const trimmedModel = templateModelName.trim();
|
|
||||||
|
|
||||||
const auth = generateThirdPartyAuth(trimmedKey);
|
|
||||||
|
|
||||||
const config = generateThirdPartyConfig(
|
|
||||||
templateProviderName || "custom",
|
|
||||||
|
|
||||||
trimmedBaseUrl,
|
|
||||||
|
|
||||||
trimmedModel,
|
|
||||||
);
|
|
||||||
|
|
||||||
onAuthChange(JSON.stringify(auth, null, 2));
|
|
||||||
|
|
||||||
onConfigChange(config);
|
|
||||||
|
|
||||||
if (onWebsiteUrlChange) {
|
|
||||||
const trimmedWebsite = templateWebsiteUrl.trim();
|
|
||||||
|
|
||||||
if (trimmedWebsite) {
|
|
||||||
onWebsiteUrlChange(trimmedWebsite);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onNameChange) {
|
|
||||||
const trimmedName = templateDisplayName.trim();
|
|
||||||
if (trimmedName) {
|
|
||||||
onNameChange(trimmedName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTemplateApiKey("");
|
|
||||||
|
|
||||||
setTemplateProviderName("");
|
|
||||||
|
|
||||||
setTemplateBaseUrl("");
|
|
||||||
|
|
||||||
setTemplateWebsiteUrl("");
|
|
||||||
|
|
||||||
setTemplateModelName("gpt-5-codex");
|
|
||||||
|
|
||||||
setTemplateDisplayName("");
|
|
||||||
|
|
||||||
closeTemplateModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTemplateInputKeyDown = (
|
|
||||||
e: React.KeyboardEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
applyTemplate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAuthChange = (value: string) => {
|
|
||||||
onAuthChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfigChange = (value: string) => {
|
|
||||||
onConfigChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommonConfigSnippetChange = (value: string) => {
|
|
||||||
onCommonConfigSnippetChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
htmlFor="codexAuth"
|
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
{t("codexConfig.authJson")}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
id="codexAuth"
|
|
||||||
value={authValue}
|
|
||||||
onChange={(e) => handleAuthChange(e.target.value)}
|
|
||||||
onBlur={onAuthBlur}
|
|
||||||
placeholder={t("codexConfig.authJsonPlaceholder")}
|
|
||||||
rows={6}
|
|
||||||
required
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="none"
|
|
||||||
spellCheck={false}
|
|
||||||
lang="en"
|
|
||||||
inputMode="text"
|
|
||||||
data-gramm="false"
|
|
||||||
data-gramm_editor="false"
|
|
||||||
data-enable-grammarly="false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{authError && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400">{authError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("codexConfig.authJsonHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label
|
|
||||||
htmlFor="codexConfig"
|
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
{t("codexConfig.configToml")}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
{t("codexConfig.writeCommonConfig")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
{t("codexConfig.editCommonConfig")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{commonConfigError && !isCommonConfigModalOpen && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
id="codexConfig"
|
|
||||||
value={configValue}
|
|
||||||
onChange={(e) => handleConfigChange(e.target.value)}
|
|
||||||
placeholder=""
|
|
||||||
rows={8}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[10rem]"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="none"
|
|
||||||
spellCheck={false}
|
|
||||||
lang="en"
|
|
||||||
inputMode="text"
|
|
||||||
data-gramm="false"
|
|
||||||
data-gramm_editor="false"
|
|
||||||
data-enable-grammarly="false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("codexConfig.configTomlHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isTemplateModalOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
closeTemplateModal();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
|
||||||
isLinux() ? "" : " backdrop-blur-sm"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white shadow-lg dark:bg-gray-900">
|
|
||||||
<div className="flex h-full min-h-0 flex-col" role="form">
|
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.quickWizard")}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeTemplateModal}
|
|
||||||
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
|
|
||||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
|
||||||
{t("codexConfig.wizardHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.apiKeyLabel")}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={templateApiKey}
|
|
||||||
ref={apiKeyInputRef}
|
|
||||||
onChange={(e) => setTemplateApiKey(e.target.value)}
|
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
|
||||||
pattern=".*\S.*"
|
|
||||||
title={t("common.enterValidValue")}
|
|
||||||
placeholder={t("codexConfig.apiKeyPlaceholder")}
|
|
||||||
required
|
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.supplierNameLabel")}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={templateDisplayName}
|
|
||||||
ref={displayNameInputRef}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTemplateDisplayName(e.target.value);
|
|
||||||
if (onNameChange) {
|
|
||||||
onNameChange(e.target.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
|
||||||
placeholder={t("codexConfig.supplierNamePlaceholder")}
|
|
||||||
required
|
|
||||||
pattern=".*\S.*"
|
|
||||||
title={t("common.enterValidValue")}
|
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("codexConfig.supplierNameHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.supplierCodeLabel")}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={templateProviderName}
|
|
||||||
onChange={(e) => setTemplateProviderName(e.target.value)}
|
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
|
||||||
placeholder={t("codexConfig.supplierCodePlaceholder")}
|
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("codexConfig.supplierCodeHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.apiUrlLabel")}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={templateBaseUrl}
|
|
||||||
ref={baseUrlInputRef}
|
|
||||||
onChange={(e) => setTemplateBaseUrl(e.target.value)}
|
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
|
||||||
placeholder={t("codexConfig.apiUrlPlaceholder")}
|
|
||||||
required
|
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.websiteLabel")}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={templateWebsiteUrl}
|
|
||||||
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
|
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
|
||||||
placeholder={t("codexConfig.websitePlaceholder")}
|
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("codexConfig.websiteHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.modelNameLabel")}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={templateModelName}
|
|
||||||
ref={modelNameInputRef}
|
|
||||||
onChange={(e) => setTemplateModelName(e.target.value)}
|
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
|
||||||
pattern=".*\S.*"
|
|
||||||
title={t("common.enterValidValue")}
|
|
||||||
placeholder={t("codexConfig.modelNamePlaceholder")}
|
|
||||||
required
|
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(templateApiKey ||
|
|
||||||
templateProviderName ||
|
|
||||||
templateBaseUrl) && (
|
|
||||||
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.configPreview")}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
auth.json
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
|
||||||
{JSON.stringify(
|
|
||||||
generateThirdPartyAuth(templateApiKey),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
config.toml
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<pre className="whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
|
||||||
{templateProviderName && templateBaseUrl
|
|
||||||
? generateThirdPartyConfig(
|
|
||||||
templateProviderName,
|
|
||||||
|
|
||||||
templateBaseUrl,
|
|
||||||
|
|
||||||
templateModelName,
|
|
||||||
)
|
|
||||||
: ""}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-100 p-6 dark:border-gray-800 dark:bg-gray-800">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeTemplateModal}
|
|
||||||
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
applyTemplate();
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{t("codexConfig.applyConfig")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCommonConfigModalOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
if (e.target === e.currentTarget) closeModal();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Backdrop - 统一背景样式 */}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
|
||||||
isLinux() ? "" : " backdrop-blur-sm"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal - 统一窗口样式 */}
|
|
||||||
|
|
||||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
{/* Header - 统一标题栏样式 */}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.editCommonConfigTitle")}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - 统一内容区域样式 */}
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("codexConfig.commonConfigHint")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleCommonConfigSnippetChange(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={`# Common Codex config
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Add your common TOML configuration here`}
|
|
||||||
rows={12}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="none"
|
|
||||||
spellCheck={false}
|
|
||||||
lang="en"
|
|
||||||
inputMode="text"
|
|
||||||
data-gramm="false"
|
|
||||||
data-gramm_editor="false"
|
|
||||||
data-enable-grammarly="false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer - 统一底部按钮样式 */}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CodexConfigEditor;
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Zap } from "lucide-react";
|
|
||||||
import { ProviderCategory } from "../../types";
|
|
||||||
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
|
|
||||||
|
|
||||||
interface Preset {
|
|
||||||
name: string;
|
|
||||||
isOfficial?: boolean;
|
|
||||||
category?: ProviderCategory;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PresetSelectorProps {
|
|
||||||
title?: string;
|
|
||||||
presets: Preset[];
|
|
||||||
selectedIndex: number | null;
|
|
||||||
onSelectPreset: (index: number) => void;
|
|
||||||
onCustomClick: () => void;
|
|
||||||
customLabel?: string;
|
|
||||||
renderCustomDescription?: () => React.ReactNode; // 新增:自定义描述渲染
|
|
||||||
}
|
|
||||||
|
|
||||||
const PresetSelector: React.FC<PresetSelectorProps> = ({
|
|
||||||
title,
|
|
||||||
presets,
|
|
||||||
selectedIndex,
|
|
||||||
onSelectPreset,
|
|
||||||
onCustomClick,
|
|
||||||
customLabel,
|
|
||||||
renderCustomDescription,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const getButtonClass = (index: number, preset?: Preset) => {
|
|
||||||
const isSelected = selectedIndex === index;
|
|
||||||
const baseClass =
|
|
||||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
if (preset?.isOfficial || preset?.category === "official") {
|
|
||||||
// Codex 官方使用黑色背景
|
|
||||||
if (preset?.name.includes("Codex")) {
|
|
||||||
return `${baseClass} bg-gray-900 text-white`;
|
|
||||||
}
|
|
||||||
// Claude 官方使用品牌色背景
|
|
||||||
return `${baseClass} bg-[#D97757] text-white`;
|
|
||||||
}
|
|
||||||
return `${baseClass} bg-blue-500 text-white`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDescription = () => {
|
|
||||||
if (selectedIndex === -1) {
|
|
||||||
// 如果提供了自定义描述渲染函数,使用它
|
|
||||||
if (renderCustomDescription) {
|
|
||||||
return renderCustomDescription();
|
|
||||||
}
|
|
||||||
return t("presetSelector.customDescription");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedIndex !== null && selectedIndex >= 0) {
|
|
||||||
const preset = presets[selectedIndex];
|
|
||||||
return preset?.isOfficial || preset?.category === "official"
|
|
||||||
? t("presetSelector.officialDescription")
|
|
||||||
: t("presetSelector.presetDescription");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
|
||||||
{title || t("presetSelector.title")}
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
|
|
||||||
onClick={onCustomClick}
|
|
||||||
>
|
|
||||||
{customLabel || t("presetSelector.custom")}
|
|
||||||
</button>
|
|
||||||
{presets.map((preset, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
className={getButtonClass(index, preset)}
|
|
||||||
onClick={() => onSelectPreset(index)}
|
|
||||||
>
|
|
||||||
{(preset.isOfficial || preset.category === "official") && (
|
|
||||||
<>
|
|
||||||
{preset.name.includes("Claude") ? (
|
|
||||||
<ClaudeIcon size={14} />
|
|
||||||
) : preset.name.includes("Codex") ? (
|
|
||||||
<CodexIcon size={14} />
|
|
||||||
) : (
|
|
||||||
<Zap size={14} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{preset.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{getDescription() && (
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{getDescription()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PresetSelector;
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Provider, UsageScript } from "../types";
|
|
||||||
import { AppType } from "../lib/tauri-api";
|
|
||||||
import { Play, Edit3, Trash2, CheckCircle2, Users, Check, BarChart3, GripVertical } from "lucide-react";
|
|
||||||
import { buttonStyles, badgeStyles, cn } from "../lib/styles";
|
|
||||||
import UsageFooter from "./UsageFooter";
|
|
||||||
import UsageScriptModal from "./UsageScriptModal";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
DragEndEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
// 不再在列表中显示分类徽章,避免造成困惑
|
|
||||||
|
|
||||||
interface ProviderListProps {
|
|
||||||
providers: Record<string, Provider>;
|
|
||||||
currentProviderId: string;
|
|
||||||
onSwitch: (id: string) => void;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
onEdit: (id: string) => void;
|
|
||||||
appType: AppType;
|
|
||||||
onNotify?: (
|
|
||||||
message: string,
|
|
||||||
type: "success" | "error",
|
|
||||||
duration?: number,
|
|
||||||
) => void;
|
|
||||||
onProvidersUpdated?: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortable Provider Item Component
|
|
||||||
interface SortableProviderItemProps {
|
|
||||||
provider: Provider;
|
|
||||||
isCurrent: boolean;
|
|
||||||
apiUrl: string;
|
|
||||||
onSwitch: (id: string) => void;
|
|
||||||
onEdit: (id: string) => void;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
onOpenUsageModal: (id: string) => void;
|
|
||||||
onUrlClick: (url: string) => Promise<void>;
|
|
||||||
appType: AppType;
|
|
||||||
t: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableProviderItem: React.FC<SortableProviderItemProps> = ({
|
|
||||||
provider,
|
|
||||||
isCurrent,
|
|
||||||
apiUrl,
|
|
||||||
onSwitch,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onOpenUsageModal,
|
|
||||||
onUrlClick,
|
|
||||||
appType,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({
|
|
||||||
id: provider.id,
|
|
||||||
animateLayoutChanges: () => false, // Disable layout animations
|
|
||||||
});
|
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition: 'none', // No transitions at all
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
zIndex: isDragging ? 1000 : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={cn(
|
|
||||||
// Base card styles without transitions that conflict with dragging
|
|
||||||
"bg-white rounded-lg border p-4 dark:bg-gray-900",
|
|
||||||
// Different border colors based on state
|
|
||||||
isCurrent
|
|
||||||
? "border-blue-500 shadow-sm bg-blue-50 dark:border-blue-400 dark:bg-blue-400/10"
|
|
||||||
: "border-gray-200 dark:border-gray-700",
|
|
||||||
// Hover effects only when not dragging
|
|
||||||
!isDragging && !isCurrent && "hover:border-gray-300 hover:shadow-sm dark:hover:border-gray-600",
|
|
||||||
// Shadow during drag
|
|
||||||
isDragging && "shadow-lg",
|
|
||||||
// Only apply transition when not dragging to prevent conflicts
|
|
||||||
!isDragging && "transition-[border-color,box-shadow] duration-200"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{/* Drag Handle */}
|
|
||||||
<div
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className="cursor-grab active:cursor-grabbing p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded mr-2 transition-colors"
|
|
||||||
title={t("provider.dragToReorder") || "拖拽以重新排序"}
|
|
||||||
>
|
|
||||||
<GripVertical size={20} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{provider.name}
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
badgeStyles.success,
|
|
||||||
!isCurrent && "invisible",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={12} />
|
|
||||||
{t("provider.currentlyUsing")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
{provider.websiteUrl ? (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onUrlClick(provider.websiteUrl!);
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
|
|
||||||
title={t("providerForm.visitWebsite", {
|
|
||||||
url: provider.websiteUrl,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{provider.websiteUrl}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="text-gray-500 dark:text-gray-400"
|
|
||||||
title={apiUrl}
|
|
||||||
>
|
|
||||||
{apiUrl}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
<button
|
|
||||||
onClick={() => onSwitch(provider.id)}
|
|
||||||
disabled={isCurrent}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[90px] justify-center whitespace-nowrap",
|
|
||||||
isCurrent
|
|
||||||
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
|
|
||||||
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCurrent ? <Check size={14} /> : <Play size={14} />}
|
|
||||||
{isCurrent ? t("provider.inUse") : t("provider.enable")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onEdit(provider.id)}
|
|
||||||
className={buttonStyles.icon}
|
|
||||||
title={t("provider.editProvider")}
|
|
||||||
>
|
|
||||||
<Edit3 size={16} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenUsageModal(provider.id)}
|
|
||||||
className={buttonStyles.icon}
|
|
||||||
title="配置用量查询"
|
|
||||||
>
|
|
||||||
<BarChart3 size={16} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(provider.id)}
|
|
||||||
disabled={isCurrent}
|
|
||||||
className={cn(
|
|
||||||
buttonStyles.icon,
|
|
||||||
isCurrent
|
|
||||||
? "text-gray-400 cursor-not-allowed"
|
|
||||||
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
|
|
||||||
)}
|
|
||||||
title={t("provider.deleteProvider")}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UsageFooter
|
|
||||||
providerId={provider.id}
|
|
||||||
appType={appType}
|
|
||||||
usageEnabled={provider.meta?.usage_script?.enabled || false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProviderList: React.FC<ProviderListProps> = ({
|
|
||||||
providers,
|
|
||||||
currentProviderId,
|
|
||||||
onSwitch,
|
|
||||||
onDelete,
|
|
||||||
onEdit,
|
|
||||||
appType,
|
|
||||||
onNotify,
|
|
||||||
onProvidersUpdated,
|
|
||||||
}) => {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [usageModalProviderId, setUsageModalProviderId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Drag and drop sensors
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 8,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
|
||||||
const getApiUrl = (provider: Provider): string => {
|
|
||||||
try {
|
|
||||||
const cfg = provider.settingsConfig;
|
|
||||||
// Claude/Anthropic: 从 env 中读取
|
|
||||||
if (cfg?.env?.ANTHROPIC_BASE_URL) {
|
|
||||||
return cfg.env.ANTHROPIC_BASE_URL;
|
|
||||||
}
|
|
||||||
// Codex: 从 TOML 配置中解析 base_url
|
|
||||||
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
|
|
||||||
// 支持单/双引号
|
|
||||||
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
|
||||||
if (match && match[2]) return match[2];
|
|
||||||
}
|
|
||||||
return t("provider.notConfigured");
|
|
||||||
} catch {
|
|
||||||
return t("provider.configError");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUrlClick = async (url: string) => {
|
|
||||||
try {
|
|
||||||
await window.api.openExternal(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(t("console.openLinkFailed"), error);
|
|
||||||
onNotify?.(
|
|
||||||
`${t("console.openLinkFailed")}: ${String(error)}`,
|
|
||||||
"error",
|
|
||||||
4000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 列表页不再提供 Claude 插件按钮,统一在"设置"中控制
|
|
||||||
|
|
||||||
// 处理用量配置保存
|
|
||||||
const handleSaveUsageScript = async (providerId: string, script: UsageScript) => {
|
|
||||||
try {
|
|
||||||
const provider = providers[providerId];
|
|
||||||
const updatedProvider = {
|
|
||||||
...provider,
|
|
||||||
meta: {
|
|
||||||
...provider.meta,
|
|
||||||
usage_script: script,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await window.api.updateProvider(updatedProvider, appType);
|
|
||||||
onNotify?.("用量查询配置已保存", "success", 2000);
|
|
||||||
// 重新加载供应商列表,触发 UsageFooter 的 useEffect
|
|
||||||
if (onProvidersUpdated) {
|
|
||||||
await onProvidersUpdated();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("保存用量配置失败:", error);
|
|
||||||
onNotify?.("保存失败", "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sort providers
|
|
||||||
const sortedProviders = React.useMemo(() => {
|
|
||||||
return Object.values(providers).sort((a, b) => {
|
|
||||||
// Priority 1: sortIndex
|
|
||||||
if (a.sortIndex !== undefined && b.sortIndex !== undefined) {
|
|
||||||
return a.sortIndex - b.sortIndex;
|
|
||||||
}
|
|
||||||
if (a.sortIndex !== undefined) return -1;
|
|
||||||
if (b.sortIndex !== undefined) return 1;
|
|
||||||
|
|
||||||
// Priority 2: createdAt
|
|
||||||
const timeA = a.createdAt || 0;
|
|
||||||
const timeB = b.createdAt || 0;
|
|
||||||
if (timeA !== 0 && timeB !== 0) return timeA - timeB;
|
|
||||||
if (timeA === 0 && timeB === 0) {
|
|
||||||
// Priority 3: name
|
|
||||||
const locale = i18n.language === "zh" ? "zh-CN" : "en-US";
|
|
||||||
return a.name.localeCompare(b.name, locale);
|
|
||||||
}
|
|
||||||
return timeA === 0 ? -1 : 1;
|
|
||||||
});
|
|
||||||
}, [providers, i18n.language]);
|
|
||||||
|
|
||||||
// Handle drag end - immediate refresh
|
|
||||||
const handleDragEnd = React.useCallback(async (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
|
|
||||||
const oldIndex = sortedProviders.findIndex((p) => p.id === active.id);
|
|
||||||
const newIndex = sortedProviders.findIndex((p) => p.id === over.id);
|
|
||||||
|
|
||||||
if (oldIndex === -1 || newIndex === -1) return;
|
|
||||||
|
|
||||||
// Calculate new sort order
|
|
||||||
const reorderedProviders = arrayMove(sortedProviders, oldIndex, newIndex);
|
|
||||||
const updates = reorderedProviders.map((provider, index) => ({
|
|
||||||
id: provider.id,
|
|
||||||
sortIndex: index,
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Save to backend and refresh immediately
|
|
||||||
await window.api.updateProvidersSortOrder(updates, appType);
|
|
||||||
onProvidersUpdated?.();
|
|
||||||
|
|
||||||
// Update tray menu to reflect new order
|
|
||||||
await window.api.updateTrayMenu();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update sort order:", error);
|
|
||||||
onNotify?.(t("provider.sortUpdateFailed") || "排序更新失败", "error");
|
|
||||||
}
|
|
||||||
}, [sortedProviders, appType, onProvidersUpdated, onNotify, t]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{sortedProviders.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
|
||||||
<Users size={24} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
|
||||||
{t("provider.noProviders")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
|
||||||
{t("provider.noProvidersDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
autoScroll={true}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={sortedProviders.map((p) => p.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sortedProviders.map((provider) => {
|
|
||||||
const isCurrent = provider.id === currentProviderId;
|
|
||||||
const apiUrl = getApiUrl(provider);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableProviderItem
|
|
||||||
key={provider.id}
|
|
||||||
provider={provider}
|
|
||||||
isCurrent={isCurrent}
|
|
||||||
apiUrl={apiUrl}
|
|
||||||
onSwitch={onSwitch}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onOpenUsageModal={setUsageModalProviderId}
|
|
||||||
onUrlClick={handleUrlClick}
|
|
||||||
appType={appType}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 用量配置模态框 */}
|
|
||||||
{usageModalProviderId && providers[usageModalProviderId] && (
|
|
||||||
<UsageScriptModal
|
|
||||||
provider={providers[usageModalProviderId]}
|
|
||||||
appType={appType!}
|
|
||||||
onClose={() => setUsageModalProviderId(null)}
|
|
||||||
onSave={(script) =>
|
|
||||||
handleSaveUsageScript(usageModalProviderId, script)
|
|
||||||
}
|
|
||||||
onNotify={onNotify}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProviderList;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { X, Download } from "lucide-react";
|
import { X, Download } from "lucide-react";
|
||||||
import { useUpdate } from "../contexts/UpdateContext";
|
import { useUpdate } from "@/contexts/UpdateContext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface UpdateBadgeProps {
|
interface UpdateBadgeProps {
|
||||||
@@ -21,7 +21,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
|||||||
className={`
|
className={`
|
||||||
flex items-center gap-1.5 px-2.5 py-1
|
flex items-center gap-1.5 px-2.5 py-1
|
||||||
bg-white dark:bg-gray-800
|
bg-white dark:bg-gray-800
|
||||||
border border-gray-200 dark:border-gray-700
|
border border-border-default
|
||||||
rounded-lg text-xs
|
rounded-lg text-xs
|
||||||
shadow-sm
|
shadow-sm
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
|
|||||||
@@ -1,63 +1,27 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
import React from "react";
|
||||||
import { UsageResult, UsageData } from "../types";
|
|
||||||
import { AppType } from "../lib/tauri-api";
|
|
||||||
import { RefreshCw, AlertCircle } from "lucide-react";
|
import { RefreshCw, AlertCircle } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { type AppId } from "@/lib/api";
|
||||||
|
import { useUsageQuery } from "@/lib/query/queries";
|
||||||
|
import { UsageData } from "../types";
|
||||||
|
|
||||||
interface UsageFooterProps {
|
interface UsageFooterProps {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
appType: AppType;
|
appId: AppId;
|
||||||
usageEnabled: boolean; // 是否启用了用量查询
|
usageEnabled: boolean; // 是否启用了用量查询
|
||||||
}
|
}
|
||||||
|
|
||||||
const UsageFooter: React.FC<UsageFooterProps> = ({
|
const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||||
providerId,
|
providerId,
|
||||||
appType,
|
appId,
|
||||||
usageEnabled,
|
usageEnabled,
|
||||||
}) => {
|
}) => {
|
||||||
const [usage, setUsage] = useState<UsageResult | null>(null);
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const {
|
||||||
|
data: usage,
|
||||||
// 记录上次请求的关键参数,防止重复请求
|
isLoading: loading,
|
||||||
const lastFetchParamsRef = useRef<string>('');
|
refetch,
|
||||||
|
} = useUsageQuery(providerId, appId, usageEnabled);
|
||||||
const fetchUsage = async () => {
|
|
||||||
// 防止并发请求
|
|
||||||
if (loading) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await window.api.queryProviderUsage(
|
|
||||||
providerId,
|
|
||||||
appType
|
|
||||||
);
|
|
||||||
setUsage(result);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("查询用量失败:", error);
|
|
||||||
setUsage({
|
|
||||||
success: false,
|
|
||||||
error: error?.message || "查询失败",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (usageEnabled) {
|
|
||||||
// 生成当前参数的唯一标识(包含 usageEnabled 状态)
|
|
||||||
const currentParams = `${providerId}-${appType}-${usageEnabled}`;
|
|
||||||
|
|
||||||
// 只有参数真正变化时才发起请求
|
|
||||||
if (currentParams !== lastFetchParamsRef.current) {
|
|
||||||
lastFetchParamsRef.current = currentParams;
|
|
||||||
fetchUsage();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果禁用了,清空记录和数据
|
|
||||||
lastFetchParamsRef.current = '';
|
|
||||||
setUsage(null);
|
|
||||||
}
|
|
||||||
}, [providerId, usageEnabled, appType]);
|
|
||||||
|
|
||||||
// 只在启用用量查询且有数据时显示
|
// 只在启用用量查询且有数据时显示
|
||||||
if (!usageEnabled || !usage) return null;
|
if (!usageEnabled || !usage) return null;
|
||||||
@@ -65,19 +29,19 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
// 错误状态
|
// 错误状态
|
||||||
if (!usage.success) {
|
if (!usage.success) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
<div className="mt-3 pt-3 border-t border-border-default ">
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
|
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
|
||||||
<AlertCircle size={14} />
|
<AlertCircle size={14} />
|
||||||
<span>{usage.error || "查询失败"}</span>
|
<span>{usage.error || t("usage.queryFailed")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 刷新按钮 */}
|
{/* 刷新按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchUsage()}
|
onClick={() => refetch()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
|
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
title="刷新用量"
|
title={t("usage.refreshUsage")}
|
||||||
>
|
>
|
||||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
@@ -92,17 +56,17 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
if (usageDataList.length === 0) return null;
|
if (usageDataList.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
<div className="mt-3 pt-3 border-t border-border-default ">
|
||||||
{/* 标题行:包含刷新按钮 */}
|
{/* 标题行:包含刷新按钮 */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
套餐用量
|
{t("usage.planUsage")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchUsage()}
|
onClick={() => refetch()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||||
title="刷新用量"
|
title={t("usage.refreshUsage")}
|
||||||
>
|
>
|
||||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
@@ -120,7 +84,17 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
|
|
||||||
// 单个套餐数据展示组件
|
// 单个套餐数据展示组件
|
||||||
const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
||||||
const { planName, extra, isValid, invalidMessage, total, used, remaining, unit } = data;
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
planName,
|
||||||
|
extra,
|
||||||
|
isValid,
|
||||||
|
invalidMessage,
|
||||||
|
total,
|
||||||
|
used,
|
||||||
|
remaining,
|
||||||
|
unit,
|
||||||
|
} = data;
|
||||||
|
|
||||||
// 判断套餐是否失效(isValid 为 false 或未定义时视为有效)
|
// 判断套餐是否失效(isValid 为 false 或未定义时视为有效)
|
||||||
const isExpired = isValid === false;
|
const isExpired = isValid === false;
|
||||||
@@ -128,7 +102,10 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* 标题部分:25% */}
|
{/* 标题部分:25% */}
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 min-w-0" style={{ width: "25%" }}>
|
<div
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400 min-w-0"
|
||||||
|
style={{ width: "25%" }}
|
||||||
|
>
|
||||||
{planName ? (
|
{planName ? (
|
||||||
<span
|
<span
|
||||||
className={`font-medium truncate block ${isExpired ? "text-red-500 dark:text-red-400" : ""}`}
|
className={`font-medium truncate block ${isExpired ? "text-red-500 dark:text-red-400" : ""}`}
|
||||||
@@ -142,7 +119,10 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 扩展字段:30% */}
|
{/* 扩展字段:30% */}
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 min-w-0 flex items-center gap-2" style={{ width: "30%" }}>
|
<div
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400 min-w-0 flex items-center gap-2"
|
||||||
|
style={{ width: "30%" }}
|
||||||
|
>
|
||||||
{extra && (
|
{extra && (
|
||||||
<span
|
<span
|
||||||
className={`truncate ${isExpired ? "text-red-500 dark:text-red-400" : ""}`}
|
className={`truncate ${isExpired ? "text-red-500 dark:text-red-400" : ""}`}
|
||||||
@@ -153,17 +133,22 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
|||||||
)}
|
)}
|
||||||
{isExpired && (
|
{isExpired && (
|
||||||
<span className="text-red-500 dark:text-red-400 font-medium text-[10px] px-1.5 py-0.5 bg-red-50 dark:bg-red-900/20 rounded flex-shrink-0">
|
<span className="text-red-500 dark:text-red-400 font-medium text-[10px] px-1.5 py-0.5 bg-red-50 dark:bg-red-900/20 rounded flex-shrink-0">
|
||||||
{invalidMessage || "已失效"}
|
{invalidMessage || t("usage.invalid")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 用量信息:45% */}
|
{/* 用量信息:45% */}
|
||||||
<div className="flex items-center justify-end gap-2 text-xs flex-shrink-0" style={{ width: "45%" }}>
|
<div
|
||||||
|
className="flex items-center justify-end gap-2 text-xs flex-shrink-0"
|
||||||
|
style={{ width: "45%" }}
|
||||||
|
>
|
||||||
{/* 总额度 */}
|
{/* 总额度 */}
|
||||||
{total !== undefined && (
|
{total !== undefined && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-500 dark:text-gray-400">总:</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t("usage.total")}
|
||||||
|
</span>
|
||||||
<span className="tabular-nums text-gray-600 dark:text-gray-400">
|
<span className="tabular-nums text-gray-600 dark:text-gray-400">
|
||||||
{total === -1 ? "∞" : total.toFixed(2)}
|
{total === -1 ? "∞" : total.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
@@ -174,7 +159,9 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
|||||||
{/* 已用额度 */}
|
{/* 已用额度 */}
|
||||||
{used !== undefined && (
|
{used !== undefined && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-500 dark:text-gray-400">使用:</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t("usage.used")}
|
||||||
|
</span>
|
||||||
<span className="tabular-nums text-gray-600 dark:text-gray-400">
|
<span className="tabular-nums text-gray-600 dark:text-gray-400">
|
||||||
{used.toFixed(2)}
|
{used.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
@@ -185,7 +172,9 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
|||||||
{/* 剩余额度 - 突出显示 */}
|
{/* 剩余额度 - 突出显示 */}
|
||||||
{remaining !== undefined && (
|
{remaining !== undefined && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-500 dark:text-gray-400">剩余:</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t("usage.remaining")}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`font-semibold tabular-nums ${
|
className={`font-semibold tabular-nums ${
|
||||||
isExpired
|
isExpired
|
||||||
@@ -200,11 +189,12 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{unit && <span className="text-gray-500 dark:text-gray-400">{unit}</span>}
|
{unit && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{unit}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default UsageFooter;
|
export default UsageFooter;
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { X, Play, Wand2 } from "lucide-react";
|
import { Play, Wand2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Provider, UsageScript } from "../types";
|
import { Provider, UsageScript } from "../types";
|
||||||
import { AppType } from "../lib/tauri-api";
|
import { usageApi, type AppId } from "@/lib/api";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import * as prettier from "prettier/standalone";
|
import * as prettier from "prettier/standalone";
|
||||||
import * as parserBabel from "prettier/parser-babel";
|
import * as parserBabel from "prettier/parser-babel";
|
||||||
import * as pluginEstree from "prettier/plugins/estree";
|
import * as pluginEstree from "prettier/plugins/estree";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface UsageScriptModalProps {
|
interface UsageScriptModalProps {
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
appType: AppType;
|
appId: AppId;
|
||||||
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (script: UsageScript) => void;
|
onSave: (script: UsageScript) => void;
|
||||||
onNotify?: (
|
|
||||||
message: string,
|
|
||||||
type: "success" | "error",
|
|
||||||
duration?: number
|
|
||||||
) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预设模板(JS 对象字面量格式)
|
// 预设模板(JS 对象字面量格式)
|
||||||
@@ -78,17 +84,22 @@ const PRESET_TEMPLATES: Record<string, string> = {
|
|||||||
|
|
||||||
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||||
provider,
|
provider,
|
||||||
appType,
|
appId,
|
||||||
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
onNotify,
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [script, setScript] = useState<UsageScript>(() => {
|
const [script, setScript] = useState<UsageScript>(() => {
|
||||||
return (
|
return (
|
||||||
provider.meta?.usage_script || {
|
provider.meta?.usage_script || {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
language: "javascript",
|
language: "javascript",
|
||||||
code: PRESET_TEMPLATES["通用模板"],
|
code: PRESET_TEMPLATES[
|
||||||
|
t("usageScript.presetTemplate") === "预设模板"
|
||||||
|
? "通用模板"
|
||||||
|
: "General"
|
||||||
|
],
|
||||||
timeout: 10,
|
timeout: 10,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -99,42 +110,50 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// 验证脚本格式
|
// 验证脚本格式
|
||||||
if (script.enabled && !script.code.trim()) {
|
if (script.enabled && !script.code.trim()) {
|
||||||
onNotify?.("脚本配置不能为空", "error");
|
toast.error(t("usageScript.scriptEmpty"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基本的 JS 语法检查(检查是否包含 return 语句)
|
// 基本的 JS 语法检查(检查是否包含 return 语句)
|
||||||
if (script.enabled && !script.code.includes("return")) {
|
if (script.enabled && !script.code.includes("return")) {
|
||||||
onNotify?.("脚本必须包含 return 语句", "error", 5000);
|
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave(script);
|
onSave(script);
|
||||||
onClose();
|
onClose();
|
||||||
onNotify?.("用量查询配置已保存", "success", 2000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
try {
|
try {
|
||||||
const result = await window.api.queryProviderUsage(
|
const result = await usageApi.query(provider.id, appId);
|
||||||
provider.id,
|
|
||||||
appType
|
|
||||||
);
|
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
// 显示所有套餐数据
|
// 显示所有套餐数据
|
||||||
const summary = result.data
|
const summary = result.data
|
||||||
.map((plan) => {
|
.map((plan) => {
|
||||||
const planInfo = plan.planName ? `[${plan.planName}]` : "";
|
const planInfo = plan.planName ? `[${plan.planName}]` : "";
|
||||||
return `${planInfo} 剩余: ${plan.remaining} ${plan.unit}`;
|
return `${planInfo} ${t("usage.remaining")} ${plan.remaining} ${plan.unit}`;
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
onNotify?.(`测试成功!${summary}`, "success", 3000);
|
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
onNotify?.(`测试失败: ${result.error || "无数据返回"}`, "error", 5000);
|
toast.error(
|
||||||
|
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
||||||
|
{
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
onNotify?.(`测试失败: ${error?.message || "未知错误"}`, "error", 5000);
|
toast.error(
|
||||||
|
`${t("usageScript.testFailed")}: ${error?.message || t("common.unknown")}`,
|
||||||
|
{
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
@@ -151,9 +170,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
printWidth: 80,
|
printWidth: 80,
|
||||||
});
|
});
|
||||||
setScript({ ...script, code: formatted.trim() });
|
setScript({ ...script, code: formatted.trim() });
|
||||||
onNotify?.("格式化成功", "success", 1000);
|
toast.success(t("usageScript.formatSuccess"), { duration: 1000 });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
onNotify?.(`格式化失败: ${error?.message || "语法错误"}`, "error", 3000);
|
toast.error(
|
||||||
|
`${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`,
|
||||||
|
{
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,22 +189,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||||
{/* Header */}
|
<DialogHeader>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<DialogTitle>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
{t("usageScript.title")} - {provider.name}
|
||||||
配置用量查询 - {provider.name}
|
</DialogTitle>
|
||||||
</h2>
|
</DialogHeader>
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content - Scrollable */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
{/* 启用开关 */}
|
{/* 启用开关 */}
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -193,7 +210,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
启用用量查询
|
{t("usageScript.enableUsageQuery")}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -202,7 +219,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
{/* 预设模板选择 */}
|
{/* 预设模板选择 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
|
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||||
预设模板
|
{t("usageScript.presetTemplate")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{Object.keys(PRESET_TEMPLATES).map((name) => (
|
{Object.keys(PRESET_TEMPLATES).map((name) => (
|
||||||
@@ -220,7 +237,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
{/* 脚本编辑器 */}
|
{/* 脚本编辑器 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
|
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||||
查询脚本(JavaScript)
|
{t("usageScript.queryScript")}
|
||||||
</label>
|
</label>
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={script.code}
|
value={script.code}
|
||||||
@@ -229,8 +246,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
language="javascript"
|
language="javascript"
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
支持变量: <code>{"{{apiKey}}"}</code>,{" "}
|
{t("usageScript.variablesHint", {
|
||||||
<code>{"{{baseUrl}}"}</code> | extractor 函数接收 API 响应的 JSON 对象
|
apiKey: "{{apiKey}}",
|
||||||
|
baseUrl: "{{baseUrl}}",
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -238,7 +257,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
超时时间(秒)
|
{t("usageScript.timeoutSeconds")}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -246,21 +265,26 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
max="30"
|
max="30"
|
||||||
value={script.timeout || 10}
|
value={script.timeout || 10}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({ ...script, timeout: parseInt(e.target.value) })
|
setScript({
|
||||||
|
...script,
|
||||||
|
timeout: parseInt(e.target.value),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 脚本说明 */}
|
{/* 脚本说明 */}
|
||||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
|
||||||
<h4 className="font-medium mb-2">脚本编写说明:</h4>
|
<h4 className="font-medium mb-2">
|
||||||
|
{t("usageScript.scriptHelp")}
|
||||||
|
</h4>
|
||||||
<div className="space-y-3 text-xs">
|
<div className="space-y-3 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<strong>配置格式:</strong>
|
<strong>{t("usageScript.configFormat")}</strong>
|
||||||
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
|
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
|
||||||
{`({
|
{`({
|
||||||
request: {
|
request: {
|
||||||
url: "{{baseUrl}}/api/usage",
|
url: "{{baseUrl}}/api/usage",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -283,25 +307,30 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<strong>extractor 返回格式(所有字段均为可选):</strong>
|
<strong>{t("usageScript.extractorFormat")}</strong>
|
||||||
<ul className="mt-1 space-y-0.5 ml-2">
|
<ul className="mt-1 space-y-0.5 ml-2">
|
||||||
<li>• <code>isValid</code>: 布尔值,套餐是否有效</li>
|
<li>{t("usageScript.fieldIsValid")}</li>
|
||||||
<li>• <code>invalidMessage</code>: 字符串,失效原因说明(当 isValid 为 false 时显示)</li>
|
<li>{t("usageScript.fieldInvalidMessage")}</li>
|
||||||
<li>• <code>remaining</code>: 数字,剩余额度</li>
|
<li>{t("usageScript.fieldRemaining")}</li>
|
||||||
<li>• <code>unit</code>: 字符串,单位(如 "USD")</li>
|
<li>{t("usageScript.fieldUnit")}</li>
|
||||||
<li>• <code>planName</code>: 字符串,套餐名称</li>
|
<li>{t("usageScript.fieldPlanName")}</li>
|
||||||
<li>• <code>total</code>: 数字,总额度</li>
|
<li>{t("usageScript.fieldTotal")}</li>
|
||||||
<li>• <code>used</code>: 数字,已用额度</li>
|
<li>{t("usageScript.fieldUsed")}</li>
|
||||||
<li>• <code>extra</code>: 字符串,扩展字段,可自由补充需要展示的文本</li>
|
<li>{t("usageScript.fieldExtra")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-gray-600 dark:text-gray-400">
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
<strong>💡 提示:</strong>
|
<strong>{t("usageScript.tips")}</strong>
|
||||||
<ul className="mt-1 space-y-0.5 ml-2">
|
<ul className="mt-1 space-y-0.5 ml-2">
|
||||||
<li>• 变量 <code>{"{{apiKey}}"}</code> 和 <code>{"{{baseUrl}}"}</code> 会自动替换</li>
|
<li>
|
||||||
<li>• extractor 函数在沙箱环境中执行,支持 ES2020+ 语法</li>
|
{t("usageScript.tip1", {
|
||||||
<li>• 整个配置必须用 <code>()</code> 包裹,形成对象字面量表达式</li>
|
apiKey: "{{apiKey}}",
|
||||||
|
baseUrl: "{{baseUrl}}",
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
<li>{t("usageScript.tip2")}</li>
|
||||||
|
<li>{t("usageScript.tip3")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,44 +340,42 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
|
||||||
|
{/* Left side - Test and Format buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={handleTest}
|
onClick={handleTest}
|
||||||
disabled={!script.enabled || testing}
|
disabled={!script.enabled || testing}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
<Play size={14} />
|
<Play size={14} />
|
||||||
{testing ? "测试中..." : "测试脚本"}
|
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={handleFormat}
|
onClick={handleFormat}
|
||||||
disabled={!script.enabled}
|
disabled={!script.enabled}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
title={t("usageScript.format")}
|
||||||
title="格式化代码 (Prettier)"
|
|
||||||
>
|
>
|
||||||
<Wand2 size={14} />
|
<Wand2 size={14} />
|
||||||
格式化
|
{t("usageScript.format")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Cancel and Save buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
onClick={onClose}
|
{t("common.cancel")}
|
||||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
</Button>
|
||||||
>
|
<Button variant="default" size="sm" onClick={handleSave}>
|
||||||
取消
|
{t("usageScript.saveConfig")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
|
|
||||||
>
|
|
||||||
保存配置
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,41 @@
|
|||||||
import React, { useMemo, useState, useEffect } from "react";
|
import React, { useMemo, useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X, Save, AlertCircle, ChevronDown, ChevronUp, AlertTriangle } from "lucide-react";
|
import { toast } from "sonner";
|
||||||
import { McpServer, McpServerSpec } from "../../types";
|
|
||||||
import {
|
import {
|
||||||
mcpPresets,
|
Save,
|
||||||
getMcpPresetWithDescription,
|
Plus,
|
||||||
} from "../../config/mcpPresets";
|
AlertCircle,
|
||||||
import { buttonStyles, inputStyles } from "../../lib/styles";
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { mcpApi, type AppId } from "@/lib/api";
|
||||||
|
import { McpServer, McpServerSpec } from "@/types";
|
||||||
|
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
|
||||||
import McpWizardModal from "./McpWizardModal";
|
import McpWizardModal from "./McpWizardModal";
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
translateMcpBackendError,
|
translateMcpBackendError,
|
||||||
} from "../../utils/errorUtils";
|
} from "@/utils/errorUtils";
|
||||||
import { AppType } from "../../lib/tauri-api";
|
|
||||||
import {
|
import {
|
||||||
validateToml,
|
|
||||||
tomlToMcpServer,
|
tomlToMcpServer,
|
||||||
extractIdFromToml,
|
extractIdFromToml,
|
||||||
mcpServerToToml,
|
mcpServerToToml,
|
||||||
} from "../../utils/tomlUtils";
|
} from "@/utils/tomlUtils";
|
||||||
|
import { useMcpValidation } from "./useMcpValidation";
|
||||||
|
|
||||||
interface McpFormModalProps {
|
interface McpFormModalProps {
|
||||||
appType: AppType;
|
appId: AppId;
|
||||||
editingId?: string;
|
editingId?: string;
|
||||||
initialData?: McpServer;
|
initialData?: McpServer;
|
||||||
onSave: (
|
onSave: (
|
||||||
@@ -31,11 +45,6 @@ interface McpFormModalProps {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
existingIds?: string[];
|
existingIds?: string[];
|
||||||
onNotify?: (
|
|
||||||
message: string,
|
|
||||||
type: "success" | "error",
|
|
||||||
duration?: number,
|
|
||||||
) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,38 +53,17 @@ interface McpFormModalProps {
|
|||||||
* Codex: 使用 TOML 格式
|
* Codex: 使用 TOML 格式
|
||||||
*/
|
*/
|
||||||
const McpFormModal: React.FC<McpFormModalProps> = ({
|
const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||||
appType,
|
appId,
|
||||||
editingId,
|
editingId,
|
||||||
initialData,
|
initialData,
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
existingIds = [],
|
existingIds = [],
|
||||||
onNotify,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
||||||
|
useMcpValidation();
|
||||||
|
|
||||||
// JSON 基本校验(返回 i18n 文案)
|
|
||||||
const validateJson = (text: string): string => {
|
|
||||||
if (!text.trim()) return "";
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text);
|
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
||||||
return t("mcp.error.jsonInvalid");
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
} catch {
|
|
||||||
return t("mcp.error.jsonInvalid");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 统一格式化 TOML 错误(本地化 + 详情)
|
|
||||||
const formatTomlError = (err: string): string => {
|
|
||||||
if (!err) return "";
|
|
||||||
if (err === "mustBeObject" || err === "parseError") {
|
|
||||||
return t("mcp.error.tomlInvalid");
|
|
||||||
}
|
|
||||||
return `${t("mcp.error.tomlInvalid")}: ${err}`;
|
|
||||||
};
|
|
||||||
const [formId, setFormId] = useState(
|
const [formId, setFormId] = useState(
|
||||||
() => editingId || initialData?.id || "",
|
() => editingId || initialData?.id || "",
|
||||||
);
|
);
|
||||||
@@ -103,11 +91,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
isEditing ? hasAdditionalInfo : false,
|
isEditing ? hasAdditionalInfo : false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 根据 appType 决定初始格式
|
// 根据 appId 决定初始格式
|
||||||
const [formConfig, setFormConfig] = useState(() => {
|
const [formConfig, setFormConfig] = useState(() => {
|
||||||
const spec = initialData?.server;
|
const spec = initialData?.server;
|
||||||
if (!spec) return "";
|
if (!spec) return "";
|
||||||
if (appType === "codex") {
|
if (appId === "codex") {
|
||||||
return mcpServerToToml(spec);
|
return mcpServerToToml(spec);
|
||||||
}
|
}
|
||||||
return JSON.stringify(spec, null, 2);
|
return JSON.stringify(spec, null, 2);
|
||||||
@@ -121,14 +109,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
const [otherSideHasConflict, setOtherSideHasConflict] = useState(false);
|
const [otherSideHasConflict, setOtherSideHasConflict] = useState(false);
|
||||||
|
|
||||||
// 判断是否使用 TOML 格式
|
// 判断是否使用 TOML 格式
|
||||||
const useToml = appType === "codex";
|
const useToml = appId === "codex";
|
||||||
const syncTargetLabel =
|
const syncTargetLabel =
|
||||||
appType === "claude" ? t("apps.codex") : t("apps.claude");
|
appId === "claude" ? t("apps.codex") : t("apps.claude");
|
||||||
const otherAppType: AppType = appType === "claude" ? "codex" : "claude";
|
const otherAppType: AppId = appId === "claude" ? "codex" : "claude";
|
||||||
const syncCheckboxId = useMemo(
|
const syncCheckboxId = useMemo(() => `sync-other-side-${appId}`, [appId]);
|
||||||
() => `sync-other-side-${appType}`,
|
|
||||||
[appType],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 检测另一侧是否有同名 MCP
|
// 检测另一侧是否有同名 MCP
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -140,8 +125,10 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const otherConfig = await window.api.getMcpConfig(otherAppType);
|
const otherConfig = await mcpApi.getConfig(otherAppType);
|
||||||
const hasConflict = Object.keys(otherConfig.servers || {}).includes(currentId);
|
const hasConflict = Object.keys(otherConfig.servers || {}).includes(
|
||||||
|
currentId,
|
||||||
|
);
|
||||||
setOtherSideHasConflict(hasConflict);
|
setOtherSideHasConflict(hasConflict);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("检查另一侧 MCP 配置失败:", error);
|
console.error("检查另一侧 MCP 配置失败:", error);
|
||||||
@@ -217,14 +204,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
if (useToml) {
|
if (useToml) {
|
||||||
const toml = mcpServerToToml(presetWithDesc.server);
|
const toml = mcpServerToToml(presetWithDesc.server);
|
||||||
setFormConfig(toml);
|
setFormConfig(toml);
|
||||||
{
|
setConfigError(validateTomlConfig(toml));
|
||||||
const err = validateToml(toml);
|
|
||||||
setConfigError(formatTomlError(err));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const json = JSON.stringify(presetWithDesc.server, null, 2);
|
const json = JSON.stringify(presetWithDesc.server, null, 2);
|
||||||
setFormConfig(json);
|
setFormConfig(json);
|
||||||
setConfigError(validateJson(json));
|
setConfigError(validateJsonConfig(json));
|
||||||
}
|
}
|
||||||
setSelectedPreset(index);
|
setSelectedPreset(index);
|
||||||
};
|
};
|
||||||
@@ -247,71 +231,27 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
setFormConfig(value);
|
setFormConfig(value);
|
||||||
|
|
||||||
if (useToml) {
|
if (useToml) {
|
||||||
// TOML 校验
|
// TOML validation (use hook's complete validation)
|
||||||
const err = validateToml(value);
|
const err = validateTomlConfig(value);
|
||||||
if (err) {
|
if (err) {
|
||||||
setConfigError(formatTomlError(err));
|
setConfigError(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试解析并做必填字段提示
|
// Try to extract ID (if user hasn't filled it yet)
|
||||||
if (value.trim()) {
|
if (value.trim() && !formId.trim()) {
|
||||||
try {
|
const extractedId = extractIdFromToml(value);
|
||||||
const server = tomlToMcpServer(value);
|
if (extractedId) {
|
||||||
if (server.type === "stdio" && !server.command?.trim()) {
|
setFormId(extractedId);
|
||||||
setConfigError(t("mcp.error.commandRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (server.type === "http" && !server.url?.trim()) {
|
|
||||||
setConfigError(t("mcp.wizard.urlRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试提取 ID(如果用户还没有填写)
|
|
||||||
if (!formId.trim()) {
|
|
||||||
const extractedId = extractIdFromToml(value);
|
|
||||||
if (extractedId) {
|
|
||||||
setFormId(extractedId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
const msg = e?.message || String(e);
|
|
||||||
setConfigError(formatTomlError(msg));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// JSON 校验
|
// JSON validation (use hook's complete validation)
|
||||||
const baseErr = validateJson(value);
|
const err = validateJsonConfig(value);
|
||||||
if (baseErr) {
|
if (err) {
|
||||||
setConfigError(baseErr);
|
setConfigError(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进一步结构校验
|
|
||||||
if (value.trim()) {
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(value);
|
|
||||||
if (obj && typeof obj === "object") {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(obj, "mcpServers")) {
|
|
||||||
setConfigError(t("mcp.error.singleServerObjectRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const typ = (obj as any)?.type;
|
|
||||||
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
|
|
||||||
setConfigError(t("mcp.error.commandRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typ === "http" && !(obj as any)?.url?.trim()) {
|
|
||||||
setConfigError(t("mcp.wizard.urlRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 解析异常已在基础校验覆盖
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfigError("");
|
setConfigError("");
|
||||||
@@ -322,27 +262,26 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
if (!formName.trim()) {
|
if (!formName.trim()) {
|
||||||
setFormName(title);
|
setFormName(title);
|
||||||
}
|
}
|
||||||
// Wizard 返回的是 JSON,根据格式决定是否需要转换
|
// Wizard returns JSON, convert based on format if needed
|
||||||
if (useToml) {
|
if (useToml) {
|
||||||
try {
|
try {
|
||||||
const server = JSON.parse(json) as McpServerSpec;
|
const server = JSON.parse(json) as McpServerSpec;
|
||||||
const toml = mcpServerToToml(server);
|
const toml = mcpServerToToml(server);
|
||||||
setFormConfig(toml);
|
setFormConfig(toml);
|
||||||
const err = validateToml(toml);
|
setConfigError(validateTomlConfig(toml));
|
||||||
setConfigError(formatTomlError(err));
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setConfigError(t("mcp.error.jsonInvalid"));
|
setConfigError(t("mcp.error.jsonInvalid"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setFormConfig(json);
|
setFormConfig(json);
|
||||||
setConfigError(validateJson(json));
|
setConfigError(validateJsonConfig(json));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const trimmedId = formId.trim();
|
const trimmedId = formId.trim();
|
||||||
if (!trimmedId) {
|
if (!trimmedId) {
|
||||||
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
|
toast.error(t("mcp.error.idRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,20 +291,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证配置格式
|
// Validate configuration format
|
||||||
let serverSpec: McpServerSpec;
|
let serverSpec: McpServerSpec;
|
||||||
|
|
||||||
if (useToml) {
|
if (useToml) {
|
||||||
// TOML 模式
|
// TOML mode
|
||||||
const tomlError = validateToml(formConfig);
|
const tomlError = validateTomlConfig(formConfig);
|
||||||
setConfigError(formatTomlError(tomlError));
|
setConfigError(tomlError);
|
||||||
if (tomlError) {
|
if (tomlError) {
|
||||||
onNotify?.(t("mcp.error.tomlInvalid"), "error", 3000);
|
toast.error(t("mcp.error.tomlInvalid"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formConfig.trim()) {
|
if (!formConfig.trim()) {
|
||||||
// 空配置
|
// Empty configuration
|
||||||
serverSpec = {
|
serverSpec = {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "",
|
command: "",
|
||||||
@@ -377,21 +316,21 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message || String(e);
|
const msg = e?.message || String(e);
|
||||||
setConfigError(formatTomlError(msg));
|
setConfigError(formatTomlError(msg));
|
||||||
onNotify?.(t("mcp.error.tomlInvalid"), "error", 4000);
|
toast.error(t("mcp.error.tomlInvalid"), { duration: 4000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// JSON 模式
|
// JSON mode
|
||||||
const jsonError = validateJson(formConfig);
|
const jsonError = validateJsonConfig(formConfig);
|
||||||
setConfigError(jsonError);
|
setConfigError(jsonError);
|
||||||
if (jsonError) {
|
if (jsonError) {
|
||||||
onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000);
|
toast.error(t("mcp.error.jsonInvalid"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formConfig.trim()) {
|
if (!formConfig.trim()) {
|
||||||
// 空配置
|
// Empty configuration
|
||||||
serverSpec = {
|
serverSpec = {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "",
|
command: "",
|
||||||
@@ -402,7 +341,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
serverSpec = JSON.parse(formConfig) as McpServerSpec;
|
serverSpec = JSON.parse(formConfig) as McpServerSpec;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setConfigError(t("mcp.error.jsonInvalid"));
|
setConfigError(t("mcp.error.jsonInvalid"));
|
||||||
onNotify?.(t("mcp.error.jsonInvalid"), "error", 4000);
|
toast.error(t("mcp.error.jsonInvalid"), { duration: 4000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,11 +349,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
|
|
||||||
// 前置必填校验
|
// 前置必填校验
|
||||||
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
|
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
|
||||||
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
|
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
|
if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
|
||||||
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
|
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,10 +365,13 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
server: serverSpec,
|
server: serverSpec,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 修复:新增 MCP 时默认启用(enabled=true)
|
||||||
|
// 编辑模式下保留原有的 enabled 状态
|
||||||
if (initialData?.enabled !== undefined) {
|
if (initialData?.enabled !== undefined) {
|
||||||
entry.enabled = initialData.enabled;
|
entry.enabled = initialData.enabled;
|
||||||
} else if (!initialData) {
|
} else {
|
||||||
delete entry.enabled;
|
// 新增模式:默认启用
|
||||||
|
entry.enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameTrimmed = (formName || trimmedId).trim();
|
const nameTrimmed = (formName || trimmedId).trim();
|
||||||
@@ -472,14 +414,14 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
const detail = extractErrorMessage(error);
|
const detail = extractErrorMessage(error);
|
||||||
const mapped = translateMcpBackendError(detail, t);
|
const mapped = translateMcpBackendError(detail, t);
|
||||||
const msg = mapped || detail || t("mcp.error.saveFailed");
|
const msg = mapped || detail || t("mcp.error.saveFailed");
|
||||||
onNotify?.(msg, "error", mapped || detail ? 6000 : 4000);
|
toast.error(msg, { duration: mapped || detail ? 6000 : 4000 });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFormTitle = () => {
|
const getFormTitle = () => {
|
||||||
if (appType === "claude") {
|
if (appId === "claude") {
|
||||||
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
|
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
|
||||||
} else {
|
} else {
|
||||||
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
|
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
|
||||||
@@ -487,275 +429,264 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
<>
|
||||||
{/* Backdrop */}
|
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
|
||||||
<div
|
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
<DialogHeader>
|
||||||
onClick={onClose}
|
<DialogTitle>{getFormTitle()}</DialogTitle>
|
||||||
/>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Content - Scrollable */}
|
||||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
{/* Header */}
|
{/* 预设选择(仅新增时展示) */}
|
||||||
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
{!isEditing && (
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<div>
|
||||||
{getFormTitle()}
|
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||||
</h3>
|
{t("mcp.presets.title")}
|
||||||
<button
|
</label>
|
||||||
onClick={onClose}
|
<div className="flex flex-wrap gap-2">
|
||||||
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
<button
|
||||||
>
|
type="button"
|
||||||
<X size={18} />
|
onClick={applyCustom}
|
||||||
</button>
|
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
</div>
|
selectedPreset === -1
|
||||||
|
? "bg-emerald-500 text-white dark:bg-emerald-600"
|
||||||
{/* Content - Scrollable */}
|
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
}`}
|
||||||
{/* 预设选择(仅新增时展示) */}
|
>
|
||||||
{!isEditing && (
|
{t("presetSelector.custom")}
|
||||||
|
</button>
|
||||||
|
{mcpPresets.map((preset, idx) => {
|
||||||
|
const descriptionKey = `mcp.presets.${preset.id}.description`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(idx)}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
selectedPreset === idx
|
||||||
|
? "bg-emerald-500 text-white dark:bg-emerald-600"
|
||||||
|
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
|
}`}
|
||||||
|
title={t(descriptionKey)}
|
||||||
|
>
|
||||||
|
{preset.id}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* ID (标题) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
<div className="flex items-center justify-between mb-2">
|
||||||
{t("mcp.presets.title")}
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
</label>
|
{t("mcp.form.title")} <span className="text-red-500">*</span>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={applyCustom}
|
|
||||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
selectedPreset === -1
|
|
||||||
? "bg-emerald-500 text-white dark:bg-emerald-600"
|
|
||||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("presetSelector.custom")}
|
|
||||||
</button>
|
|
||||||
{mcpPresets.map((preset, idx) => {
|
|
||||||
const descriptionKey = `mcp.presets.${preset.id}.description`;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => applyPreset(idx)}
|
|
||||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
selectedPreset === idx
|
|
||||||
? "bg-emerald-500 text-white dark:bg-emerald-600"
|
|
||||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
||||||
}`}
|
|
||||||
title={t(descriptionKey)}
|
|
||||||
>
|
|
||||||
{preset.id}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* ID (标题) */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{t("mcp.form.title")} <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
{!isEditing && idError && (
|
|
||||||
<span className="text-xs text-red-500 dark:text-red-400">
|
|
||||||
{idError}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
className={inputStyles.text}
|
|
||||||
placeholder={t("mcp.form.titlePlaceholder")}
|
|
||||||
value={formId}
|
|
||||||
onChange={(e) => handleIdChange(e.target.value)}
|
|
||||||
disabled={isEditing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t("mcp.form.name")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className={inputStyles.text}
|
|
||||||
placeholder={t("mcp.form.namePlaceholder")}
|
|
||||||
value={formName}
|
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 可折叠的附加信息按钮 */}
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowMetadata(!showMetadata)}
|
|
||||||
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
{showMetadata ? (
|
|
||||||
<ChevronUp size={16} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
)}
|
|
||||||
{t("mcp.form.additionalInfo")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 附加信息区域(可折叠) */}
|
|
||||||
{showMetadata && (
|
|
||||||
<>
|
|
||||||
{/* Description (描述) */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t("mcp.form.description")}
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
{!isEditing && idError && (
|
||||||
className={inputStyles.text}
|
<span className="text-xs text-red-500 dark:text-red-400">
|
||||||
placeholder={t("mcp.form.descriptionPlaceholder")}
|
{idError}
|
||||||
value={formDescription}
|
</span>
|
||||||
onChange={(e) => setFormDescription(e.target.value)}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Input
|
||||||
{/* Tags */}
|
type="text"
|
||||||
<div>
|
placeholder={t("mcp.form.titlePlaceholder")}
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
value={formId}
|
||||||
{t("mcp.form.tags")}
|
onChange={(e) => handleIdChange(e.target.value)}
|
||||||
</label>
|
disabled={isEditing}
|
||||||
<input
|
|
||||||
className={inputStyles.text}
|
|
||||||
placeholder={t("mcp.form.tagsPlaceholder")}
|
|
||||||
value={formTags}
|
|
||||||
onChange={(e) => setFormTags(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Homepage */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t("mcp.form.homepage")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className={inputStyles.text}
|
|
||||||
placeholder={t("mcp.form.homepagePlaceholder")}
|
|
||||||
value={formHomepage}
|
|
||||||
onChange={(e) => setFormHomepage(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Docs */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t("mcp.form.docs")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className={inputStyles.text}
|
|
||||||
placeholder={t("mcp.form.docsPlaceholder")}
|
|
||||||
value={formDocs}
|
|
||||||
onChange={(e) => setFormDocs(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 配置输入框(根据格式显示 JSON 或 TOML) */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{useToml ? t("mcp.form.tomlConfig") : t("mcp.form.jsonConfig")}
|
|
||||||
</label>
|
|
||||||
{(isEditing || selectedPreset === -1) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsWizardOpen(true)}
|
|
||||||
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
|
||||||
>
|
|
||||||
{t("mcp.form.useWizard")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
className={`${inputStyles.text} h-48 resize-none font-mono text-xs`}
|
|
||||||
placeholder={
|
|
||||||
useToml
|
|
||||||
? t("mcp.form.tomlPlaceholder")
|
|
||||||
: t("mcp.form.jsonPlaceholder")
|
|
||||||
}
|
|
||||||
value={formConfig}
|
|
||||||
onChange={(e) => handleConfigChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
{configError && (
|
|
||||||
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
|
|
||||||
<AlertCircle size={16} />
|
|
||||||
<span>{configError}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex-shrink-0 flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
|
||||||
{/* 双端同步选项 */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id={syncCheckboxId}
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-800"
|
|
||||||
checked={syncOtherSide}
|
|
||||||
onChange={(event) => setSyncOtherSide(event.target.checked)}
|
|
||||||
/>
|
/>
|
||||||
<label
|
|
||||||
htmlFor={syncCheckboxId}
|
|
||||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
|
||||||
title={t("mcp.form.syncOtherSideHint", { target: syncTargetLabel })}
|
|
||||||
>
|
|
||||||
{t("mcp.form.syncOtherSide", { target: syncTargetLabel })}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
{syncOtherSide && otherSideHasConflict && (
|
|
||||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
{/* Name */}
|
||||||
<AlertTriangle size={14} />
|
<div>
|
||||||
<span className="text-xs font-medium">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{t("mcp.form.willOverwriteWarning", { target: syncTargetLabel })}
|
{t("mcp.form.name")}
|
||||||
</span>
|
</label>
|
||||||
</div>
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("mcp.form.namePlaceholder")}
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 可折叠的附加信息按钮 */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMetadata(!showMetadata)}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
{showMetadata ? (
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
)}
|
||||||
|
{t("mcp.form.additionalInfo")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 附加信息区域(可折叠) */}
|
||||||
|
{showMetadata && (
|
||||||
|
<>
|
||||||
|
{/* Description (描述) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t("mcp.form.description")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("mcp.form.descriptionPlaceholder")}
|
||||||
|
value={formDescription}
|
||||||
|
onChange={(e) => setFormDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t("mcp.form.tags")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("mcp.form.tagsPlaceholder")}
|
||||||
|
value={formTags}
|
||||||
|
onChange={(e) => setFormTags(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Homepage */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t("mcp.form.homepage")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("mcp.form.homepagePlaceholder")}
|
||||||
|
value={formHomepage}
|
||||||
|
onChange={(e) => setFormHomepage(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Docs */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t("mcp.form.docs")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("mcp.form.docsPlaceholder")}
|
||||||
|
value={formDocs}
|
||||||
|
onChange={(e) => setFormDocs(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 配置输入框(根据格式显示 JSON 或 TOML) */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{useToml
|
||||||
|
? t("mcp.form.tomlConfig")
|
||||||
|
: t("mcp.form.jsonConfig")}
|
||||||
|
</label>
|
||||||
|
{(isEditing || selectedPreset === -1) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsWizardOpen(true)}
|
||||||
|
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
{t("mcp.form.useWizard")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
className="h-48 resize-none font-mono text-xs"
|
||||||
|
placeholder={
|
||||||
|
useToml
|
||||||
|
? t("mcp.form.tomlPlaceholder")
|
||||||
|
: t("mcp.form.jsonPlaceholder")
|
||||||
|
}
|
||||||
|
value={formConfig}
|
||||||
|
onChange={(e) => handleConfigChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{configError && (
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>{configError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* Footer */}
|
||||||
<div className="flex items-center gap-3">
|
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
|
||||||
<button
|
{/* 双端同步选项 */}
|
||||||
onClick={onClose}
|
<div className="flex items-center gap-3">
|
||||||
className="px-4 py-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium"
|
<div className="flex items-center gap-2">
|
||||||
>
|
<input
|
||||||
{t("common.cancel")}
|
id={syncCheckboxId}
|
||||||
</button>
|
type="checkbox"
|
||||||
<button
|
className="h-4 w-4 rounded border-border-default text-emerald-600 focus:ring-emerald-500 dark:bg-gray-800"
|
||||||
onClick={handleSubmit}
|
checked={syncOtherSide}
|
||||||
disabled={saving || (!isEditing && !!idError)}
|
onChange={(event) => setSyncOtherSide(event.target.checked)}
|
||||||
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
|
/>
|
||||||
>
|
<label
|
||||||
<Save size={16} />
|
htmlFor={syncCheckboxId}
|
||||||
{saving
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||||
? t("common.saving")
|
title={t("mcp.form.syncOtherSideHint", {
|
||||||
: isEditing
|
target: syncTargetLabel,
|
||||||
? t("common.save")
|
})}
|
||||||
: t("common.add")}
|
>
|
||||||
</button>
|
{t("mcp.form.syncOtherSide", { target: syncTargetLabel })}
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{syncOtherSide && otherSideHasConflict && (
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{t("mcp.form.willOverwriteWarning", {
|
||||||
|
target: syncTargetLabel,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={saving || (!isEditing && !!idError)}
|
||||||
|
variant="mcp"
|
||||||
|
>
|
||||||
|
{isEditing ? <Save size={16} /> : <Plus size={16} />}
|
||||||
|
{saving
|
||||||
|
? t("common.saving")
|
||||||
|
: isEditing
|
||||||
|
? t("common.save")
|
||||||
|
: t("common.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Wizard Modal */}
|
{/* Wizard Modal */}
|
||||||
<McpWizardModal
|
<McpWizardModal
|
||||||
isOpen={isWizardOpen}
|
isOpen={isWizardOpen}
|
||||||
onClose={() => setIsWizardOpen(false)}
|
onClose={() => setIsWizardOpen(false)}
|
||||||
onApply={handleWizardApply}
|
onApply={handleWizardApply}
|
||||||
onNotify={onNotify}
|
|
||||||
initialTitle={formId}
|
initialTitle={formId}
|
||||||
initialServer={wizardInitialSpec}
|
initialServer={wizardInitialSpec}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Edit3, Trash2 } from "lucide-react";
|
import { Edit3, Trash2 } from "lucide-react";
|
||||||
import { McpServer } from "../../types";
|
import { Button } from "@/components/ui/button";
|
||||||
import { mcpPresets } from "../../config/mcpPresets";
|
import { settingsApi } from "@/lib/api";
|
||||||
import { cardStyles, buttonStyles, cn } from "../../lib/styles";
|
import { McpServer } from "@/types";
|
||||||
|
import { mcpPresets } from "@/config/mcpPresets";
|
||||||
import McpToggle from "./McpToggle";
|
import McpToggle from "./McpToggle";
|
||||||
|
|
||||||
interface McpListItemProps {
|
interface McpListItemProps {
|
||||||
@@ -44,14 +45,14 @@ const McpListItem: React.FC<McpListItemProps> = ({
|
|||||||
const url = docsUrl || homepageUrl;
|
const url = docsUrl || homepageUrl;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
try {
|
try {
|
||||||
await window.api.openExternal(url);
|
await settingsApi.openExternal(url);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(cardStyles.interactive, "!p-4 h-16")}>
|
<div className="h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
|
||||||
<div className="flex items-center gap-4 h-full">
|
<div className="flex items-center gap-4 h-full">
|
||||||
{/* 左侧:Toggle 开关 */}
|
{/* 左侧:Toggle 开关 */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
@@ -82,32 +83,36 @@ const McpListItem: React.FC<McpListItemProps> = ({
|
|||||||
{/* 右侧:操作按钮 */}
|
{/* 右侧:操作按钮 */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{docsUrl && (
|
{docsUrl && (
|
||||||
<button
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={openDocs}
|
onClick={openDocs}
|
||||||
className={buttonStyles.ghost}
|
|
||||||
title={t("mcp.presets.docs")}
|
title={t("mcp.presets.docs")}
|
||||||
>
|
>
|
||||||
{t("mcp.presets.docs")}
|
{t("mcp.presets.docs")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => onEdit(id)}
|
onClick={() => onEdit(id)}
|
||||||
className={buttonStyles.icon}
|
|
||||||
title={t("common.edit")}
|
title={t("common.edit")}
|
||||||
>
|
>
|
||||||
<Edit3 size={16} />
|
<Edit3 size={16} />
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => onDelete(id)}
|
onClick={() => onDelete(id)}
|
||||||
className={cn(
|
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||||
buttonStyles.icon,
|
|
||||||
"hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10",
|
|
||||||
)}
|
|
||||||
title={t("common.delete")}
|
title={t("common.delete")}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +1,33 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X, Plus, Server, Check } from "lucide-react";
|
import { Plus, Server, Check } from "lucide-react";
|
||||||
import { McpServer } from "../../types";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { type AppId } from "@/lib/api";
|
||||||
|
import { McpServer } from "@/types";
|
||||||
|
import { useMcpActions } from "@/hooks/useMcpActions";
|
||||||
import McpListItem from "./McpListItem";
|
import McpListItem from "./McpListItem";
|
||||||
import McpFormModal from "./McpFormModal";
|
import McpFormModal from "./McpFormModal";
|
||||||
import { ConfirmDialog } from "../ConfirmDialog";
|
import { ConfirmDialog } from "../ConfirmDialog";
|
||||||
import {
|
|
||||||
extractErrorMessage,
|
|
||||||
translateMcpBackendError,
|
|
||||||
} from "../../utils/errorUtils";
|
|
||||||
// 预设相关逻辑已迁移到“新增 MCP”面板,列表此处无需引用
|
|
||||||
import { buttonStyles } from "../../lib/styles";
|
|
||||||
import { AppType } from "../../lib/tauri-api";
|
|
||||||
|
|
||||||
interface McpPanelProps {
|
interface McpPanelProps {
|
||||||
onClose: () => void;
|
open: boolean;
|
||||||
onNotify?: (
|
onOpenChange: (open: boolean) => void;
|
||||||
message: string,
|
appId: AppId;
|
||||||
type: "success" | "error",
|
|
||||||
duration?: number,
|
|
||||||
) => void;
|
|
||||||
appType: AppType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP 管理面板
|
* MCP 管理面板
|
||||||
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
|
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
|
||||||
*/
|
*/
|
||||||
const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
|
const McpPanel: React.FC<McpPanelProps> = ({ open, onOpenChange, appId }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [servers, setServers] = useState<Record<string, McpServer>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
@@ -40,66 +37,30 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const reload = async () => {
|
// Use MCP actions hook
|
||||||
setLoading(true);
|
const { servers, loading, reload, toggleEnabled, saveServer, deleteServer } =
|
||||||
try {
|
useMcpActions(appId);
|
||||||
const cfg = await window.api.getMcpConfig(appType);
|
|
||||||
setServers(cfg.servers || {});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
try {
|
try {
|
||||||
// 初始化:仅从对应客户端导入已有 MCP,不做“预设落库”
|
// Initialize: only import existing MCPs from corresponding client
|
||||||
if (appType === "claude") {
|
if (appId === "claude") {
|
||||||
await window.api.importMcpFromClaude();
|
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
|
||||||
} else if (appType === "codex") {
|
await mcpApi.importFromClaude();
|
||||||
await window.api.importMcpFromCodex();
|
} else if (appId === "codex") {
|
||||||
|
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
|
||||||
|
await mcpApi.importFromCodex();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("MCP 初始化导入失败(忽略继续)", e);
|
console.warn("MCP initialization import failed (ignored)", e);
|
||||||
} finally {
|
} finally {
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
setup();
|
setup();
|
||||||
// appType 改变时重新初始化
|
// Re-initialize when appId changes
|
||||||
}, [appType]);
|
}, [appId, reload]);
|
||||||
|
|
||||||
const handleToggle = async (id: string, enabled: boolean) => {
|
|
||||||
// 乐观更新:立即更新 UI
|
|
||||||
const previousServers = servers;
|
|
||||||
setServers((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id]: {
|
|
||||||
...prev[id],
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 后台调用 API
|
|
||||||
await window.api.setMcpEnabled(appType, id, enabled);
|
|
||||||
onNotify?.(
|
|
||||||
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
|
|
||||||
"success",
|
|
||||||
1500,
|
|
||||||
);
|
|
||||||
} catch (e: any) {
|
|
||||||
// 失败时回滚
|
|
||||||
setServers(previousServers);
|
|
||||||
const detail = extractErrorMessage(e);
|
|
||||||
const mapped = translateMcpBackendError(detail, t);
|
|
||||||
onNotify?.(
|
|
||||||
mapped || detail || t("mcp.error.saveFailed"),
|
|
||||||
"error",
|
|
||||||
mapped || detail ? 6000 : 5000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (id: string) => {
|
const handleEdit = (id: string) => {
|
||||||
setEditingId(id);
|
setEditingId(id);
|
||||||
@@ -118,18 +79,10 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
|
|||||||
message: t("mcp.confirm.deleteMessage", { id }),
|
message: t("mcp.confirm.deleteMessage", { id }),
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
try {
|
try {
|
||||||
await window.api.deleteMcpServerInConfig(appType, id);
|
await deleteServer(id);
|
||||||
await reload();
|
|
||||||
setConfirmDialog(null);
|
setConfirmDialog(null);
|
||||||
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
|
} catch (e) {
|
||||||
} catch (e: any) {
|
// Error already handled by useMcpActions
|
||||||
const detail = extractErrorMessage(e);
|
|
||||||
const mapped = translateMcpBackendError(detail, t);
|
|
||||||
onNotify?.(
|
|
||||||
mapped || detail || t("mcp.error.deleteFailed"),
|
|
||||||
"error",
|
|
||||||
mapped || detail ? 6000 : 5000,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -140,26 +93,9 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
|
|||||||
server: McpServer,
|
server: McpServer,
|
||||||
options?: { syncOtherSide?: boolean },
|
options?: { syncOtherSide?: boolean },
|
||||||
) => {
|
) => {
|
||||||
try {
|
await saveServer(id, server, options);
|
||||||
const payload: McpServer = { ...server, id };
|
setIsFormOpen(false);
|
||||||
await window.api.upsertMcpServerInConfig(appType, id, payload, {
|
setEditingId(null);
|
||||||
syncOtherSide: options?.syncOtherSide,
|
|
||||||
});
|
|
||||||
await reload();
|
|
||||||
setIsFormOpen(false);
|
|
||||||
setEditingId(null);
|
|
||||||
onNotify?.(t("mcp.msg.saved"), "success", 1500);
|
|
||||||
} catch (e: any) {
|
|
||||||
const detail = extractErrorMessage(e);
|
|
||||||
const mapped = translateMcpBackendError(detail, t);
|
|
||||||
onNotify?.(
|
|
||||||
mapped || detail || t("mcp.error.saveFailed"),
|
|
||||||
"error",
|
|
||||||
mapped || detail ? 6000 : 5000,
|
|
||||||
);
|
|
||||||
// 继续抛出错误,让表单层可以给到直观反馈(避免被更高层遮挡)
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
const handleCloseForm = () => {
|
||||||
@@ -178,120 +114,101 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const panelTitle =
|
const panelTitle =
|
||||||
appType === "claude" ? t("mcp.claudeTitle") : t("mcp.codexTitle");
|
appId === "claude" ? t("mcp.claudeTitle") : t("mcp.codexTitle");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<>
|
||||||
{/* Backdrop */}
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<div
|
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
<DialogHeader>
|
||||||
onClick={onClose}
|
<div className="flex items-center justify-between pr-8">
|
||||||
/>
|
<DialogTitle>{panelTitle}</DialogTitle>
|
||||||
|
<Button type="button" variant="mcp" onClick={handleAdd}>
|
||||||
{/* Panel */}
|
<Plus size={16} />
|
||||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-3xl w-full mx-4 overflow-hidden flex flex-col max-h-[85vh] min-h-[600px]">
|
{t("mcp.add")}
|
||||||
{/* Header */}
|
</Button>
|
||||||
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{panelTitle}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleAdd}
|
|
||||||
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
{t("mcp.add")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Section */}
|
|
||||||
<div className="flex-shrink-0 px-6 pt-4 pb-2">
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("mcp.serverCount", { count: Object.keys(servers).length })} ·{" "}
|
|
||||||
{t("mcp.enabledCount", { count: enabledCount })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - Scrollable */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
|
||||||
{t("mcp.loading")}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</DialogHeader>
|
||||||
(() => {
|
|
||||||
const hasAny = serverEntries.length > 0;
|
{/* Info Section */}
|
||||||
if (!hasAny) {
|
<div className="flex-shrink-0 px-6 py-4">
|
||||||
return (
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
<div className="text-center py-12">
|
{t("mcp.serverCount", { count: Object.keys(servers).length })} ·{" "}
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
{t("mcp.enabledCount", { count: enabledCount })}
|
||||||
<Server
|
</div>
|
||||||
size={24}
|
</div>
|
||||||
className="text-gray-400 dark:text-gray-500"
|
|
||||||
/>
|
{/* Content - Scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 pb-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
{t("mcp.loading")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const hasAny = serverEntries.length > 0;
|
||||||
|
if (!hasAny) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||||
|
<Server
|
||||||
|
size={24}
|
||||||
|
className="text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
{t("mcp.empty")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{t("mcp.emptyDescription")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
);
|
||||||
{t("mcp.empty")}
|
}
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
return (
|
||||||
{t("mcp.emptyDescription")}
|
<div className="space-y-3">
|
||||||
</p>
|
{/* 已安装 */}
|
||||||
|
{serverEntries.map(([id, server]) => (
|
||||||
|
<McpListItem
|
||||||
|
key={`installed-${id}`}
|
||||||
|
id={id}
|
||||||
|
server={server}
|
||||||
|
onToggle={toggleEnabled}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 预设已移至"新增 MCP"面板中展示与套用 */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<DialogFooter>
|
||||||
<div className="space-y-3">
|
<Button
|
||||||
{/* 已安装 */}
|
type="button"
|
||||||
{serverEntries.map(([id, server]) => (
|
variant="mcp"
|
||||||
<McpListItem
|
onClick={() => onOpenChange(false)}
|
||||||
key={`installed-${id}`}
|
>
|
||||||
id={id}
|
<Check size={16} />
|
||||||
server={server}
|
{t("common.done")}
|
||||||
onToggle={handleToggle}
|
</Button>
|
||||||
onEdit={handleEdit}
|
</DialogFooter>
|
||||||
onDelete={handleDelete}
|
</DialogContent>
|
||||||
/>
|
</Dialog>
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 预设已移至"新增 MCP"面板中展示与套用 */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex-shrink-0 flex items-center justify-end p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
|
|
||||||
>
|
|
||||||
<Check size={16} />
|
|
||||||
{t("common.done")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Modal */}
|
{/* Form Modal */}
|
||||||
{isFormOpen && (
|
{isFormOpen && (
|
||||||
<McpFormModal
|
<McpFormModal
|
||||||
appType={appType}
|
appId={appId}
|
||||||
editingId={editingId || undefined}
|
editingId={editingId || undefined}
|
||||||
initialData={editingId ? servers[editingId] : undefined}
|
initialData={editingId ? servers[editingId] : undefined}
|
||||||
existingIds={Object.keys(servers)}
|
existingIds={Object.keys(servers)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onClose={handleCloseForm}
|
onClose={handleCloseForm}
|
||||||
onNotify={onNotify}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -305,7 +222,7 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
|
|||||||
onCancel={() => setConfirmDialog(null)}
|
onCancel={() => setConfirmDialog(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X, Save } from "lucide-react";
|
import { toast } from "sonner";
|
||||||
import { McpServerSpec } from "../../types";
|
import { Save } from "lucide-react";
|
||||||
import { isLinux } from "../../lib/platform";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { McpServerSpec } from "@/types";
|
||||||
|
|
||||||
interface McpWizardModalProps {
|
interface McpWizardModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onApply: (title: string, json: string) => void;
|
onApply: (title: string, json: string) => void;
|
||||||
onNotify?: (
|
|
||||||
message: string,
|
|
||||||
type: "success" | "error",
|
|
||||||
duration?: number,
|
|
||||||
) => void;
|
|
||||||
initialTitle?: string;
|
initialTitle?: string;
|
||||||
initialServer?: McpServerSpec;
|
initialServer?: McpServerSpec;
|
||||||
}
|
}
|
||||||
@@ -73,7 +76,6 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onApply,
|
onApply,
|
||||||
onNotify,
|
|
||||||
initialTitle,
|
initialTitle,
|
||||||
initialServer,
|
initialServer,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -130,15 +132,15 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
if (!wizardTitle.trim()) {
|
if (!wizardTitle.trim()) {
|
||||||
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
|
toast.error(t("mcp.error.idRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (wizardType === "stdio" && !wizardCommand.trim()) {
|
if (wizardType === "stdio" && !wizardCommand.trim()) {
|
||||||
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
|
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (wizardType === "http" && !wizardUrl.trim()) {
|
if (wizardType === "http" && !wizardUrl.trim()) {
|
||||||
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
|
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +175,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
setWizardTitle(title);
|
setWizardTitle(title);
|
||||||
|
|
||||||
const resolvedType =
|
const resolvedType =
|
||||||
initialServer?.type ??
|
initialServer?.type ?? (initialServer?.url ? "http" : "stdio");
|
||||||
(initialServer?.url ? "http" : "stdio");
|
|
||||||
|
|
||||||
setWizardType(resolvedType);
|
setWizardType(resolvedType);
|
||||||
|
|
||||||
@@ -203,7 +204,9 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
setWizardArgs(Array.isArray(argsValue) ? argsValue.join("\n") : "");
|
setWizardArgs(Array.isArray(argsValue) ? argsValue.join("\n") : "");
|
||||||
const envCandidate = initialServer?.env;
|
const envCandidate = initialServer?.env;
|
||||||
const env =
|
const env =
|
||||||
envCandidate && typeof envCandidate === "object" ? envCandidate : undefined;
|
envCandidate && typeof envCandidate === "object"
|
||||||
|
? envCandidate
|
||||||
|
: undefined;
|
||||||
setWizardEnv(
|
setWizardEnv(
|
||||||
env
|
env
|
||||||
? Object.entries(env)
|
? Object.entries(env)
|
||||||
@@ -215,47 +218,19 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
setWizardHeaders("");
|
setWizardHeaders("");
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const preview = generatePreview();
|
const preview = generatePreview();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||||
className="fixed inset-0 z-[70] flex items-center justify-center"
|
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||||
onMouseDown={(e) => {
|
<DialogHeader>
|
||||||
if (e.target === e.currentTarget) {
|
<DialogTitle>{t("mcp.wizard.title")}</DialogTitle>
|
||||||
handleClose();
|
</DialogHeader>
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
|
||||||
isLinux() ? "" : " backdrop-blur-sm"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white shadow-lg dark:bg-gray-900">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{t("mcp.wizard.title")}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
{/* Hint */}
|
{/* Hint */}
|
||||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
<div className="rounded-lg border border-border-active bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
{t("mcp.wizard.hint")}
|
{t("mcp.wizard.hint")}
|
||||||
</p>
|
</p>
|
||||||
@@ -277,7 +252,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setWizardType(e.target.value as "stdio" | "http")
|
setWizardType(e.target.value as "stdio" | "http")
|
||||||
}
|
}
|
||||||
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.typeStdio")}
|
{t("mcp.wizard.typeStdio")}
|
||||||
@@ -291,7 +266,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setWizardType(e.target.value as "stdio" | "http")
|
setWizardType(e.target.value as "stdio" | "http")
|
||||||
}
|
}
|
||||||
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.typeHttp")}
|
{t("mcp.wizard.typeHttp")}
|
||||||
@@ -311,7 +286,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardTitle(e.target.value)}
|
onChange={(e) => setWizardTitle(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t("mcp.form.titlePlaceholder")}
|
placeholder={t("mcp.form.titlePlaceholder")}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -330,7 +305,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardCommand(e.target.value)}
|
onChange={(e) => setWizardCommand(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t("mcp.wizard.commandPlaceholder")}
|
placeholder={t("mcp.wizard.commandPlaceholder")}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -344,7 +319,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardArgs(e.target.value)}
|
onChange={(e) => setWizardArgs(e.target.value)}
|
||||||
placeholder={t("mcp.wizard.argsPlaceholder")}
|
placeholder={t("mcp.wizard.argsPlaceholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -358,7 +333,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardEnv(e.target.value)}
|
onChange={(e) => setWizardEnv(e.target.value)}
|
||||||
placeholder={t("mcp.wizard.envPlaceholder")}
|
placeholder={t("mcp.wizard.envPlaceholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -379,7 +354,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardUrl(e.target.value)}
|
onChange={(e) => setWizardUrl(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t("mcp.wizard.urlPlaceholder")}
|
placeholder={t("mcp.wizard.urlPlaceholder")}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -393,7 +368,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardHeaders(e.target.value)}
|
onChange={(e) => setWizardHeaders(e.target.value)}
|
||||||
placeholder={t("mcp.wizard.headersPlaceholder")}
|
placeholder={t("mcp.wizard.headersPlaceholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -406,7 +381,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
wizardEnv ||
|
wizardEnv ||
|
||||||
wizardUrl ||
|
wizardUrl ||
|
||||||
wizardHeaders) && (
|
wizardHeaders) && (
|
||||||
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
|
<div className="space-y-2 border-t border-border-default pt-4 ">
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.preview")}
|
{t("mcp.wizard.preview")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -418,25 +393,17 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-100 p-6 dark:border-gray-800 dark:bg-gray-800">
|
<DialogFooter className="gap-3 pt-4">
|
||||||
<button
|
<Button type="button" variant="ghost" onClick={handleClose}>
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="button" variant="mcp" onClick={handleApply}>
|
||||||
type="button"
|
|
||||||
onClick={handleApply}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
{t("mcp.wizard.apply")}
|
{t("mcp.wizard.apply")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
94
src/components/mcp/useMcpValidation.ts
Normal file
94
src/components/mcp/useMcpValidation.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { validateToml, tomlToMcpServer } from "@/utils/tomlUtils";
|
||||||
|
|
||||||
|
export function useMcpValidation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// JSON basic validation (returns i18n text)
|
||||||
|
const validateJson = (text: string): string => {
|
||||||
|
if (!text.trim()) return "";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return t("mcp.error.jsonInvalid");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
} catch {
|
||||||
|
return t("mcp.error.jsonInvalid");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unified TOML error formatting (localization + details)
|
||||||
|
const formatTomlError = (err: string): string => {
|
||||||
|
if (!err) return "";
|
||||||
|
if (err === "mustBeObject" || err === "parseError") {
|
||||||
|
return t("mcp.error.tomlInvalid");
|
||||||
|
}
|
||||||
|
return `${t("mcp.error.tomlInvalid")}: ${err}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full TOML validation (including required field checks)
|
||||||
|
const validateTomlConfig = (value: string): string => {
|
||||||
|
const err = validateToml(value);
|
||||||
|
if (err) {
|
||||||
|
return formatTomlError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse and check required fields
|
||||||
|
if (value.trim()) {
|
||||||
|
try {
|
||||||
|
const server = tomlToMcpServer(value);
|
||||||
|
if (server.type === "stdio" && !server.command?.trim()) {
|
||||||
|
return t("mcp.error.commandRequired");
|
||||||
|
}
|
||||||
|
if (server.type === "http" && !server.url?.trim()) {
|
||||||
|
return t("mcp.wizard.urlRequired");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.message || String(e);
|
||||||
|
return formatTomlError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full JSON validation (including structure checks)
|
||||||
|
const validateJsonConfig = (value: string): string => {
|
||||||
|
const baseErr = validateJson(value);
|
||||||
|
if (baseErr) {
|
||||||
|
return baseErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Further structure validation
|
||||||
|
if (value.trim()) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(value);
|
||||||
|
if (obj && typeof obj === "object") {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, "mcpServers")) {
|
||||||
|
return t("mcp.error.singleServerObjectRequired");
|
||||||
|
}
|
||||||
|
|
||||||
|
const typ = (obj as any)?.type;
|
||||||
|
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
|
||||||
|
return t("mcp.error.commandRequired");
|
||||||
|
}
|
||||||
|
if (typ === "http" && !(obj as any)?.url?.trim()) {
|
||||||
|
return t("mcp.wizard.urlRequired");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Parse errors already covered by base validation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateJson,
|
||||||
|
formatTomlError,
|
||||||
|
validateTomlConfig,
|
||||||
|
validateJsonConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
27
src/components/mode-toggle.tsx
Normal file
27
src/components/mode-toggle.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTheme } from "@/components/theme-provider";
|
||||||
|
|
||||||
|
export function ModeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
// 如果当前是 dark 或 system(且系统是暗色),切换到 light
|
||||||
|
// 否则切换到 dark
|
||||||
|
if (theme === "dark") {
|
||||||
|
setTheme("light");
|
||||||
|
} else {
|
||||||
|
setTheme("dark");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="icon" onClick={toggleTheme}>
|
||||||
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">{t("common.toggleTheme")}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
src/components/providers/AddProviderDialog.tsx
Normal file
179
src/components/providers/AddProviderDialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Provider, CustomEndpoint } from "@/types";
|
||||||
|
import type { AppId } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
ProviderForm,
|
||||||
|
type ProviderFormValues,
|
||||||
|
} from "@/components/providers/forms/ProviderForm";
|
||||||
|
import { providerPresets } from "@/config/providerPresets";
|
||||||
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||||
|
|
||||||
|
interface AddProviderDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
appId: AppId;
|
||||||
|
onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddProviderDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
appId,
|
||||||
|
onSubmit,
|
||||||
|
}: AddProviderDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (values: ProviderFormValues) => {
|
||||||
|
const parsedConfig = JSON.parse(values.settingsConfig) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
// 构造基础提交数据
|
||||||
|
const providerData: Omit<Provider, "id"> = {
|
||||||
|
name: values.name.trim(),
|
||||||
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
|
settingsConfig: parsedConfig,
|
||||||
|
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||||
|
...(values.meta ? { meta: values.meta } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCustomEndpoints =
|
||||||
|
providerData.meta?.custom_endpoints &&
|
||||||
|
Object.keys(providerData.meta.custom_endpoints).length > 0;
|
||||||
|
|
||||||
|
if (!hasCustomEndpoints) {
|
||||||
|
// 收集端点候选(仅在缺少自定义端点时兜底)
|
||||||
|
// 1. 从预设配置中获取 endpointCandidates
|
||||||
|
// 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url)
|
||||||
|
const urlSet = new Set<string>();
|
||||||
|
|
||||||
|
const addUrl = (rawUrl?: string) => {
|
||||||
|
const url = (rawUrl || "").trim().replace(/\/+$/, "");
|
||||||
|
if (url && url.startsWith("http")) {
|
||||||
|
urlSet.add(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (values.presetId) {
|
||||||
|
if (appId === "claude") {
|
||||||
|
const presets = providerPresets;
|
||||||
|
const presetIndex = parseInt(
|
||||||
|
values.presetId.replace("claude-", ""),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!isNaN(presetIndex) &&
|
||||||
|
presetIndex >= 0 &&
|
||||||
|
presetIndex < presets.length
|
||||||
|
) {
|
||||||
|
const preset = presets[presetIndex];
|
||||||
|
if (preset?.endpointCandidates) {
|
||||||
|
preset.endpointCandidates.forEach(addUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (appId === "codex") {
|
||||||
|
const presets = codexProviderPresets;
|
||||||
|
const presetIndex = parseInt(values.presetId.replace("codex-", ""));
|
||||||
|
if (
|
||||||
|
!isNaN(presetIndex) &&
|
||||||
|
presetIndex >= 0 &&
|
||||||
|
presetIndex < presets.length
|
||||||
|
) {
|
||||||
|
const preset = presets[presetIndex];
|
||||||
|
if (Array.isArray(preset.endpointCandidates)) {
|
||||||
|
preset.endpointCandidates.forEach(addUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appId === "claude") {
|
||||||
|
const env = parsedConfig.env as Record<string, any> | undefined;
|
||||||
|
if (env?.ANTHROPIC_BASE_URL) {
|
||||||
|
addUrl(env.ANTHROPIC_BASE_URL);
|
||||||
|
}
|
||||||
|
} else if (appId === "codex") {
|
||||||
|
const config = parsedConfig.config as string | undefined;
|
||||||
|
if (config) {
|
||||||
|
const baseUrlMatch = config.match(
|
||||||
|
/base_url\s*=\s*["']([^"']+)["']/,
|
||||||
|
);
|
||||||
|
if (baseUrlMatch?.[1]) {
|
||||||
|
addUrl(baseUrlMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = Array.from(urlSet);
|
||||||
|
if (urls.length > 0) {
|
||||||
|
const now = Date.now();
|
||||||
|
const customEndpoints: Record<string, CustomEndpoint> = {};
|
||||||
|
urls.forEach((url) => {
|
||||||
|
customEndpoints[url] = {
|
||||||
|
url,
|
||||||
|
addedAt: now,
|
||||||
|
lastUsed: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
providerData.meta = {
|
||||||
|
...(providerData.meta ?? {}),
|
||||||
|
custom_endpoints: customEndpoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit(providerData);
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
[appId, onSubmit, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitLabel =
|
||||||
|
appId === "claude"
|
||||||
|
? t("provider.addClaudeProvider")
|
||||||
|
: t("provider.addCodexProvider");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{submitLabel}</DialogTitle>
|
||||||
|
<DialogDescription>{t("provider.addProviderHint")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<ProviderForm
|
||||||
|
appId={appId}
|
||||||
|
submitLabel={t("common.add")}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
showButtons={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="provider-form">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("common.add")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/components/providers/EditProviderDialog.tsx
Normal file
153
src/components/providers/EditProviderDialog.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Save } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Provider } from "@/types";
|
||||||
|
import {
|
||||||
|
ProviderForm,
|
||||||
|
type ProviderFormValues,
|
||||||
|
} from "@/components/providers/forms/ProviderForm";
|
||||||
|
import { providersApi, vscodeApi, type AppId } from "@/lib/api";
|
||||||
|
|
||||||
|
interface EditProviderDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
provider: Provider | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (provider: Provider) => Promise<void> | void;
|
||||||
|
appId: AppId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditProviderDialog({
|
||||||
|
open,
|
||||||
|
provider,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
appId,
|
||||||
|
}: EditProviderDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 默认使用传入的 provider.settingsConfig,若当前编辑对象是“当前生效供应商”,则尝试读取实时配置替换初始值
|
||||||
|
const [liveSettings, setLiveSettings] = useState<Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
if (!open || !provider) {
|
||||||
|
setLiveSettings(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const currentId = await providersApi.getCurrent(appId);
|
||||||
|
if (currentId && provider.id === currentId) {
|
||||||
|
try {
|
||||||
|
const live = (await vscodeApi.getLiveProviderSettings(
|
||||||
|
appId,
|
||||||
|
)) as Record<string, unknown>;
|
||||||
|
if (!cancelled && live && typeof live === "object") {
|
||||||
|
setLiveSettings(live);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
|
||||||
|
if (!cancelled) setLiveSettings(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!cancelled) setLiveSettings(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, provider, appId]);
|
||||||
|
|
||||||
|
const initialSettingsConfig = useMemo(() => {
|
||||||
|
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
}, [liveSettings, provider]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (values: ProviderFormValues) => {
|
||||||
|
if (!provider) return;
|
||||||
|
|
||||||
|
const parsedConfig = JSON.parse(values.settingsConfig) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
const updatedProvider: Provider = {
|
||||||
|
...provider,
|
||||||
|
name: values.name.trim(),
|
||||||
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
|
settingsConfig: parsedConfig,
|
||||||
|
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||||
|
// 保留或更新 meta 字段
|
||||||
|
...(values.meta ? { meta: values.meta } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSubmit(updatedProvider);
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
[onSubmit, onOpenChange, provider],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("provider.editProvider")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("provider.editProviderHint")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<ProviderForm
|
||||||
|
appId={appId}
|
||||||
|
submitLabel={t("common.save")}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
initialData={{
|
||||||
|
name: provider.name,
|
||||||
|
websiteUrl: provider.websiteUrl,
|
||||||
|
// 若读取到实时配置则优先使用
|
||||||
|
settingsConfig: initialSettingsConfig,
|
||||||
|
category: provider.category,
|
||||||
|
meta: provider.meta,
|
||||||
|
}}
|
||||||
|
showButtons={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="provider-form">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/providers/ProviderActions.tsx
Normal file
83
src/components/providers/ProviderActions.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { BarChart3, Check, Edit, Play, Trash2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ProviderActionsProps {
|
||||||
|
isCurrent: boolean;
|
||||||
|
onSwitch: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onConfigureUsage: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderActions({
|
||||||
|
isCurrent,
|
||||||
|
onSwitch,
|
||||||
|
onEdit,
|
||||||
|
onConfigureUsage,
|
||||||
|
onDelete,
|
||||||
|
}: ProviderActionsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isCurrent ? "secondary" : "default"}
|
||||||
|
onClick={onSwitch}
|
||||||
|
disabled={isCurrent}
|
||||||
|
className={cn(
|
||||||
|
"w-20",
|
||||||
|
isCurrent &&
|
||||||
|
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCurrent ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
{t("provider.inUse")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
{t("provider.enable")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onEdit}
|
||||||
|
title={t("common.edit")}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onConfigureUsage}
|
||||||
|
title={t("provider.configureUsage")}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={isCurrent ? undefined : onDelete}
|
||||||
|
title={t("common.delete")}
|
||||||
|
className={cn(
|
||||||
|
!isCurrent && "hover:text-red-500 dark:hover:text-red-400",
|
||||||
|
isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
src/components/providers/ProviderCard.tsx
Normal file
189
src/components/providers/ProviderCard.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { MoveVertical, Copy } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type {
|
||||||
|
DraggableAttributes,
|
||||||
|
DraggableSyntheticListeners,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import type { Provider } from "@/types";
|
||||||
|
import type { AppId } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||||
|
import UsageFooter from "@/components/UsageFooter";
|
||||||
|
|
||||||
|
interface DragHandleProps {
|
||||||
|
attributes: DraggableAttributes;
|
||||||
|
listeners: DraggableSyntheticListeners;
|
||||||
|
isDragging: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderCardProps {
|
||||||
|
provider: Provider;
|
||||||
|
isCurrent: boolean;
|
||||||
|
appId: AppId;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
onSwitch: (provider: Provider) => void;
|
||||||
|
onEdit: (provider: Provider) => void;
|
||||||
|
onDelete: (provider: Provider) => void;
|
||||||
|
onConfigureUsage: (provider: Provider) => void;
|
||||||
|
onOpenWebsite: (url: string) => void;
|
||||||
|
onDuplicate: (provider: Provider) => void;
|
||||||
|
dragHandleProps?: DragHandleProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||||
|
if (provider.websiteUrl) {
|
||||||
|
return provider.websiteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = provider.settingsConfig;
|
||||||
|
|
||||||
|
if (config && typeof config === "object") {
|
||||||
|
const envBase = (config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL;
|
||||||
|
if (typeof envBase === "string" && envBase.trim()) {
|
||||||
|
return envBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = (config as Record<string, any>)?.config;
|
||||||
|
|
||||||
|
if (typeof baseUrl === "string" && baseUrl.includes("base_url")) {
|
||||||
|
const match = baseUrl.match(/base_url\s*=\s*['"]([^'"]+)['"]/);
|
||||||
|
if (match?.[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackText;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProviderCard({
|
||||||
|
provider,
|
||||||
|
isCurrent,
|
||||||
|
appId,
|
||||||
|
isEditMode = false,
|
||||||
|
onSwitch,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onConfigureUsage,
|
||||||
|
onOpenWebsite,
|
||||||
|
onDuplicate,
|
||||||
|
dragHandleProps,
|
||||||
|
}: ProviderCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const fallbackUrlText = t("provider.notConfigured", {
|
||||||
|
defaultValue: "未配置接口地址",
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayUrl = useMemo(() => {
|
||||||
|
return extractApiUrl(provider, fallbackUrlText);
|
||||||
|
}, [provider, fallbackUrlText]);
|
||||||
|
|
||||||
|
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||||
|
|
||||||
|
const handleOpenWebsite = () => {
|
||||||
|
if (!displayUrl || displayUrl === fallbackUrlText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenWebsite(displayUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg bg-card p-4 shadow-sm",
|
||||||
|
"transition-[border-color,background-color,box-shadow,ring] duration-200",
|
||||||
|
isCurrent
|
||||||
|
? "border border-border-default bg-primary/5 ring-2 ring-blue-500/30 dark:ring-blue-400/30"
|
||||||
|
: "border border-border-default hover:border-border-hover",
|
||||||
|
dragHandleProps?.isDragging &&
|
||||||
|
"cursor-grabbing border-active border-border-dragging shadow-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 overflow-hidden",
|
||||||
|
"transition-[max-width,opacity] duration-200 ease-in-out",
|
||||||
|
isEditMode ? "max-w-20 opacity-100" : "max-w-0 opacity-0",
|
||||||
|
)}
|
||||||
|
aria-hidden={!isEditMode}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 cursor-grab active:cursor-grabbing",
|
||||||
|
dragHandleProps?.isDragging && "cursor-grabbing",
|
||||||
|
)}
|
||||||
|
aria-label={t("provider.dragHandle")}
|
||||||
|
disabled={!isEditMode}
|
||||||
|
{...(dragHandleProps?.attributes ?? {})}
|
||||||
|
{...(dragHandleProps?.listeners ?? {})}
|
||||||
|
>
|
||||||
|
<MoveVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
onClick={() => onDuplicate(provider)}
|
||||||
|
disabled={!isEditMode}
|
||||||
|
aria-label={t("provider.duplicate")}
|
||||||
|
title={t("provider.duplicate")}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 min-h-[20px]">
|
||||||
|
<h3 className="text-base font-semibold leading-none">
|
||||||
|
{provider.name}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500 dark:text-green-400 transition-opacity duration-200",
|
||||||
|
isCurrent ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("provider.currentlyUsing")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenWebsite}
|
||||||
|
className="inline-flex items-center text-sm text-blue-500 transition-colors hover:underline dark:text-blue-400"
|
||||||
|
title={displayUrl}
|
||||||
|
>
|
||||||
|
<span className="truncate">{displayUrl}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProviderActions
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
onSwitch={() => onSwitch(provider)}
|
||||||
|
onEdit={() => onEdit(provider)}
|
||||||
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
|
onDelete={() => onDelete(provider)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UsageFooter
|
||||||
|
providerId={provider.id}
|
||||||
|
appId={appId}
|
||||||
|
usageEnabled={usageEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/providers/ProviderEmptyState.tsx
Normal file
28
src/components/providers/ProviderEmptyState.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Users } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface ProviderEmptyStateProps {
|
||||||
|
onCreate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 p-10 text-center">
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Users className="h-7 w-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">{t("provider.noProviders")}</h3>
|
||||||
|
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||||
|
{t("provider.noProvidersDescription")}
|
||||||
|
</p>
|
||||||
|
{onCreate && (
|
||||||
|
<Button className="mt-6" onClick={onCreate}>
|
||||||
|
{t("provider.addProvider")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/components/providers/ProviderList.tsx
Normal file
160
src/components/providers/ProviderList.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { DndContext, closestCenter } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import type { Provider } from "@/types";
|
||||||
|
import type { AppId } from "@/lib/api";
|
||||||
|
import { useDragSort } from "@/hooks/useDragSort";
|
||||||
|
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||||
|
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||||
|
|
||||||
|
interface ProviderListProps {
|
||||||
|
providers: Record<string, Provider>;
|
||||||
|
currentProviderId: string;
|
||||||
|
appId: AppId;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
onSwitch: (provider: Provider) => void;
|
||||||
|
onEdit: (provider: Provider) => void;
|
||||||
|
onDelete: (provider: Provider) => void;
|
||||||
|
onDuplicate: (provider: Provider) => void;
|
||||||
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
|
onOpenWebsite: (url: string) => void;
|
||||||
|
onCreate?: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderList({
|
||||||
|
providers,
|
||||||
|
currentProviderId,
|
||||||
|
appId,
|
||||||
|
isEditMode = false,
|
||||||
|
onSwitch,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
|
onConfigureUsage,
|
||||||
|
onOpenWebsite,
|
||||||
|
onCreate,
|
||||||
|
isLoading = false,
|
||||||
|
}: ProviderListProps) {
|
||||||
|
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||||
|
providers,
|
||||||
|
appId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-28 w-full rounded-lg border border-dashed border-muted-foreground/40 bg-muted/40"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortedProviders.length === 0) {
|
||||||
|
return <ProviderEmptyState onCreate={onCreate} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortedProviders.map((provider) => provider.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedProviders.map((provider) => (
|
||||||
|
<SortableProviderCard
|
||||||
|
key={provider.id}
|
||||||
|
provider={provider}
|
||||||
|
isCurrent={provider.id === currentProviderId}
|
||||||
|
appId={appId}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
onSwitch={onSwitch}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onConfigureUsage={onConfigureUsage}
|
||||||
|
onOpenWebsite={onOpenWebsite}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableProviderCardProps {
|
||||||
|
provider: Provider;
|
||||||
|
isCurrent: boolean;
|
||||||
|
appId: AppId;
|
||||||
|
isEditMode: boolean;
|
||||||
|
onSwitch: (provider: Provider) => void;
|
||||||
|
onEdit: (provider: Provider) => void;
|
||||||
|
onDelete: (provider: Provider) => void;
|
||||||
|
onDuplicate: (provider: Provider) => void;
|
||||||
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
|
onOpenWebsite: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableProviderCard({
|
||||||
|
provider,
|
||||||
|
isCurrent,
|
||||||
|
appId,
|
||||||
|
isEditMode,
|
||||||
|
onSwitch,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
|
onConfigureUsage,
|
||||||
|
onOpenWebsite,
|
||||||
|
}: SortableProviderCardProps) {
|
||||||
|
const {
|
||||||
|
setNodeRef,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: provider.id });
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style}>
|
||||||
|
<ProviderCard
|
||||||
|
provider={provider}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
appId={appId}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
onSwitch={onSwitch}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onConfigureUsage={
|
||||||
|
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||||
|
}
|
||||||
|
onOpenWebsite={onOpenWebsite}
|
||||||
|
dragHandleProps={{
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
isDragging,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,8 +30,8 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
|||||||
|
|
||||||
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
||||||
disabled
|
disabled
|
||||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
? "bg-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||||
: "border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
: "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
51
src/components/providers/forms/BasicFormFields.tsx
Normal file
51
src/components/providers/forms/BasicFormFields.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import type { ProviderFormData } from "@/lib/schemas/provider";
|
||||||
|
|
||||||
|
interface BasicFormFieldsProps {
|
||||||
|
form: UseFormReturn<ProviderFormData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("provider.name")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder={t("provider.namePlaceholder")} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="websiteUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("provider.websiteUrl")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="https://" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
src/components/providers/forms/ClaudeFormFields.tsx
Normal file
229
src/components/providers/forms/ClaudeFormFields.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FormLabel } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||||
|
import KimiModelSelector from "./KimiModelSelector";
|
||||||
|
import { ApiKeySection, EndpointField } from "./shared";
|
||||||
|
import type { ProviderCategory } from "@/types";
|
||||||
|
import type { TemplateValueConfig } from "@/config/providerPresets";
|
||||||
|
|
||||||
|
interface EndpointCandidate {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeFormFieldsProps {
|
||||||
|
// API Key
|
||||||
|
shouldShowApiKey: boolean;
|
||||||
|
apiKey: string;
|
||||||
|
onApiKeyChange: (key: string) => void;
|
||||||
|
category?: ProviderCategory;
|
||||||
|
shouldShowApiKeyLink: boolean;
|
||||||
|
websiteUrl: string;
|
||||||
|
|
||||||
|
// Template Values
|
||||||
|
templateValueEntries: Array<[string, TemplateValueConfig]>;
|
||||||
|
templateValues: Record<string, TemplateValueConfig>;
|
||||||
|
templatePresetName: string;
|
||||||
|
onTemplateValueChange: (key: string, value: string) => void;
|
||||||
|
|
||||||
|
// Base URL
|
||||||
|
shouldShowSpeedTest: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
onBaseUrlChange: (url: string) => void;
|
||||||
|
isEndpointModalOpen: boolean;
|
||||||
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
|
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||||
|
|
||||||
|
// Model Selector
|
||||||
|
shouldShowKimiSelector: boolean;
|
||||||
|
shouldShowModelSelector: boolean;
|
||||||
|
claudeModel: string;
|
||||||
|
claudeSmallFastModel: string;
|
||||||
|
onModelChange: (
|
||||||
|
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
|
value: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
// Kimi Model Selector
|
||||||
|
kimiAnthropicModel: string;
|
||||||
|
kimiAnthropicSmallFastModel: string;
|
||||||
|
onKimiModelChange: (
|
||||||
|
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
|
value: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
// Speed Test Endpoints
|
||||||
|
speedTestEndpoints: EndpointCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClaudeFormFields({
|
||||||
|
shouldShowApiKey,
|
||||||
|
apiKey,
|
||||||
|
onApiKeyChange,
|
||||||
|
category,
|
||||||
|
shouldShowApiKeyLink,
|
||||||
|
websiteUrl,
|
||||||
|
templateValueEntries,
|
||||||
|
templateValues,
|
||||||
|
templatePresetName,
|
||||||
|
onTemplateValueChange,
|
||||||
|
shouldShowSpeedTest,
|
||||||
|
baseUrl,
|
||||||
|
onBaseUrlChange,
|
||||||
|
isEndpointModalOpen,
|
||||||
|
onEndpointModalToggle,
|
||||||
|
onCustomEndpointsChange,
|
||||||
|
shouldShowKimiSelector,
|
||||||
|
shouldShowModelSelector,
|
||||||
|
claudeModel,
|
||||||
|
claudeSmallFastModel,
|
||||||
|
onModelChange,
|
||||||
|
kimiAnthropicModel,
|
||||||
|
kimiAnthropicSmallFastModel,
|
||||||
|
onKimiModelChange,
|
||||||
|
speedTestEndpoints,
|
||||||
|
}: ClaudeFormFieldsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* API Key 输入框 */}
|
||||||
|
{shouldShowApiKey && (
|
||||||
|
<ApiKeySection
|
||||||
|
value={apiKey}
|
||||||
|
onChange={onApiKeyChange}
|
||||||
|
category={category}
|
||||||
|
shouldShowLink={shouldShowApiKeyLink}
|
||||||
|
websiteUrl={websiteUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 模板变量输入 */}
|
||||||
|
{templateValueEntries.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FormLabel>
|
||||||
|
{t("providerForm.parameterConfig", {
|
||||||
|
name: templatePresetName,
|
||||||
|
defaultValue: `${templatePresetName} 参数配置`,
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{templateValueEntries.map(([key, config]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<FormLabel htmlFor={`template-${key}`}>
|
||||||
|
{config.label}
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
id={`template-${key}`}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={
|
||||||
|
templateValues[key]?.editorValue ??
|
||||||
|
config.editorValue ??
|
||||||
|
config.defaultValue ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
onChange={(e) => onTemplateValueChange(key, e.target.value)}
|
||||||
|
placeholder={config.placeholder || config.label}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Base URL 输入框 */}
|
||||||
|
{shouldShowSpeedTest && (
|
||||||
|
<EndpointField
|
||||||
|
id="baseUrl"
|
||||||
|
label={t("providerForm.apiEndpoint")}
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={onBaseUrlChange}
|
||||||
|
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
||||||
|
hint={t("providerForm.apiHint")}
|
||||||
|
onManageClick={() => onEndpointModalToggle(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 端点测速弹窗 */}
|
||||||
|
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||||
|
<EndpointSpeedTest
|
||||||
|
appId="claude"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={onBaseUrlChange}
|
||||||
|
initialEndpoints={speedTestEndpoints}
|
||||||
|
visible={isEndpointModalOpen}
|
||||||
|
onClose={() => onEndpointModalToggle(false)}
|
||||||
|
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 模型选择器 */}
|
||||||
|
{shouldShowModelSelector && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* ANTHROPIC_MODEL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel htmlFor="claudeModel">
|
||||||
|
{t("providerForm.anthropicModel", {
|
||||||
|
defaultValue: "主模型",
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
id="claudeModel"
|
||||||
|
type="text"
|
||||||
|
value={claudeModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
onModelChange("ANTHROPIC_MODEL", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("providerForm.modelPlaceholder", {
|
||||||
|
defaultValue: "claude-3-7-sonnet-20250219",
|
||||||
|
})}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ANTHROPIC_SMALL_FAST_MODEL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel htmlFor="claudeSmallFastModel">
|
||||||
|
{t("providerForm.anthropicSmallFastModel", {
|
||||||
|
defaultValue: "快速模型",
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
id="claudeSmallFastModel"
|
||||||
|
type="text"
|
||||||
|
value={claudeSmallFastModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("providerForm.smallModelPlaceholder", {
|
||||||
|
defaultValue: "claude-3-5-haiku-20241022",
|
||||||
|
})}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerForm.modelHelper", {
|
||||||
|
defaultValue:
|
||||||
|
"可选:指定默认使用的 Claude 模型,留空则使用系统默认。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kimi 模型选择器 */}
|
||||||
|
{shouldShowKimiSelector && (
|
||||||
|
<KimiModelSelector
|
||||||
|
apiKey={apiKey}
|
||||||
|
anthropicModel={kimiAnthropicModel}
|
||||||
|
anthropicSmallFastModel={kimiAnthropicSmallFastModel}
|
||||||
|
onModelChange={onKimiModelChange}
|
||||||
|
disabled={category === "official"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/providers/forms/CodexCommonConfigModal.tsx
Normal file
85
src/components/providers/forms/CodexCommonConfigModal.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Save } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface CodexCommonConfigModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodexCommonConfigModal - Common Codex configuration editor modal
|
||||||
|
* Allows editing of common TOML configuration shared across providers
|
||||||
|
*/
|
||||||
|
export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent
|
||||||
|
zIndex="nested"
|
||||||
|
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-0">
|
||||||
|
<DialogTitle>{t("codexConfig.editCommonConfigTitle")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("codexConfig.commonConfigHint")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={`# Common Codex config
|
||||||
|
|
||||||
|
# Add your common TOML configuration here`}
|
||||||
|
rows={12}
|
||||||
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active transition-colors resize-y"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={onClose} className="gap-2">
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
134
src/components/providers/forms/CodexConfigEditor.tsx
Normal file
134
src/components/providers/forms/CodexConfigEditor.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
||||||
|
import { CodexQuickWizardModal } from "./CodexQuickWizardModal";
|
||||||
|
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
|
||||||
|
|
||||||
|
interface CodexConfigEditorProps {
|
||||||
|
authValue: string;
|
||||||
|
|
||||||
|
configValue: string;
|
||||||
|
|
||||||
|
onAuthChange: (value: string) => void;
|
||||||
|
|
||||||
|
onConfigChange: (value: string) => void;
|
||||||
|
|
||||||
|
onAuthBlur?: () => void;
|
||||||
|
|
||||||
|
useCommonConfig: boolean;
|
||||||
|
|
||||||
|
onCommonConfigToggle: (checked: boolean) => void;
|
||||||
|
|
||||||
|
commonConfigSnippet: string;
|
||||||
|
|
||||||
|
onCommonConfigSnippetChange: (value: string) => void;
|
||||||
|
|
||||||
|
commonConfigError: string;
|
||||||
|
|
||||||
|
authError: string;
|
||||||
|
|
||||||
|
configError: string; // config.toml 错误提示
|
||||||
|
|
||||||
|
isCustomMode?: boolean; // 是否为自定义模式
|
||||||
|
|
||||||
|
onWebsiteUrlChange?: (url: string) => void; // 更新网址回调
|
||||||
|
|
||||||
|
isTemplateModalOpen?: boolean; // 模态框状态
|
||||||
|
|
||||||
|
setIsTemplateModalOpen?: (open: boolean) => void; // 设置模态框状态
|
||||||
|
|
||||||
|
onNameChange?: (name: string) => void; // 更新供应商名称回调
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||||
|
authValue,
|
||||||
|
configValue,
|
||||||
|
onAuthChange,
|
||||||
|
onConfigChange,
|
||||||
|
onAuthBlur,
|
||||||
|
useCommonConfig,
|
||||||
|
onCommonConfigToggle,
|
||||||
|
commonConfigSnippet,
|
||||||
|
onCommonConfigSnippetChange,
|
||||||
|
commonConfigError,
|
||||||
|
authError,
|
||||||
|
configError,
|
||||||
|
onWebsiteUrlChange,
|
||||||
|
onNameChange,
|
||||||
|
isTemplateModalOpen: externalTemplateModalOpen,
|
||||||
|
setIsTemplateModalOpen: externalSetTemplateModalOpen,
|
||||||
|
}) => {
|
||||||
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Use internal state or external state
|
||||||
|
const [internalTemplateModalOpen, setInternalTemplateModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
const isTemplateModalOpen =
|
||||||
|
externalTemplateModalOpen ?? internalTemplateModalOpen;
|
||||||
|
const setIsTemplateModalOpen =
|
||||||
|
externalSetTemplateModalOpen ?? setInternalTemplateModalOpen;
|
||||||
|
|
||||||
|
// Auto-open common config modal if there's an error
|
||||||
|
useEffect(() => {
|
||||||
|
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||||
|
setIsCommonConfigModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||||
|
|
||||||
|
const handleQuickWizardApply = (
|
||||||
|
auth: string,
|
||||||
|
config: string,
|
||||||
|
extras: { websiteUrl?: string; displayName?: string },
|
||||||
|
) => {
|
||||||
|
onAuthChange(auth);
|
||||||
|
onConfigChange(config);
|
||||||
|
|
||||||
|
if (onWebsiteUrlChange && extras.websiteUrl) {
|
||||||
|
onWebsiteUrlChange(extras.websiteUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onNameChange && extras.displayName) {
|
||||||
|
onNameChange(extras.displayName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Auth JSON Section */}
|
||||||
|
<CodexAuthSection
|
||||||
|
value={authValue}
|
||||||
|
onChange={onAuthChange}
|
||||||
|
onBlur={onAuthBlur}
|
||||||
|
error={authError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Config TOML Section */}
|
||||||
|
<CodexConfigSection
|
||||||
|
value={configValue}
|
||||||
|
onChange={onConfigChange}
|
||||||
|
useCommonConfig={useCommonConfig}
|
||||||
|
onCommonConfigToggle={onCommonConfigToggle}
|
||||||
|
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||||
|
commonConfigError={commonConfigError}
|
||||||
|
configError={configError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Quick Wizard Modal */}
|
||||||
|
<CodexQuickWizardModal
|
||||||
|
isOpen={isTemplateModalOpen}
|
||||||
|
onClose={() => setIsTemplateModalOpen(false)}
|
||||||
|
onApply={handleQuickWizardApply}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Common Config Modal */}
|
||||||
|
<CodexCommonConfigModal
|
||||||
|
isOpen={isCommonConfigModalOpen}
|
||||||
|
onClose={() => setIsCommonConfigModalOpen(false)}
|
||||||
|
value={commonConfigSnippet}
|
||||||
|
onChange={onCommonConfigSnippetChange}
|
||||||
|
error={commonConfigError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CodexConfigEditor;
|
||||||
149
src/components/providers/forms/CodexConfigSections.tsx
Normal file
149
src/components/providers/forms/CodexConfigSections.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface CodexAuthSectionProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodexAuthSection - Auth JSON editor section
|
||||||
|
*/
|
||||||
|
export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="codexAuth"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{t("codexConfig.authJson")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="codexAuth"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
placeholder={t("codexConfig.authJsonPlaceholder")}
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("codexConfig.authJsonHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CodexConfigSectionProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
useCommonConfig: boolean;
|
||||||
|
onCommonConfigToggle: (checked: boolean) => void;
|
||||||
|
onEditCommonConfig: () => void;
|
||||||
|
commonConfigError?: string;
|
||||||
|
configError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodexConfigSection - Config TOML editor section
|
||||||
|
*/
|
||||||
|
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
useCommonConfig,
|
||||||
|
onCommonConfigToggle,
|
||||||
|
onEditCommonConfig,
|
||||||
|
commonConfigError,
|
||||||
|
configError,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label
|
||||||
|
htmlFor="codexConfig"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{t("codexConfig.configToml")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useCommonConfig}
|
||||||
|
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||||
|
/>
|
||||||
|
{t("codexConfig.writeCommonConfig")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditCommonConfig}
|
||||||
|
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{t("codexConfig.editCommonConfig")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{commonConfigError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||||
|
{commonConfigError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="codexConfig"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder=""
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{configError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("codexConfig.configTomlHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
src/components/providers/forms/CodexFormFields.tsx
Normal file
94
src/components/providers/forms/CodexFormFields.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||||
|
import { ApiKeySection, EndpointField } from "./shared";
|
||||||
|
import type { ProviderCategory } from "@/types";
|
||||||
|
|
||||||
|
interface EndpointCandidate {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexFormFieldsProps {
|
||||||
|
// API Key
|
||||||
|
codexApiKey: string;
|
||||||
|
onApiKeyChange: (key: string) => void;
|
||||||
|
category?: ProviderCategory;
|
||||||
|
shouldShowApiKeyLink: boolean;
|
||||||
|
websiteUrl: string;
|
||||||
|
|
||||||
|
// Base URL
|
||||||
|
shouldShowSpeedTest: boolean;
|
||||||
|
codexBaseUrl: string;
|
||||||
|
onBaseUrlChange: (url: string) => void;
|
||||||
|
isEndpointModalOpen: boolean;
|
||||||
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
|
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||||
|
|
||||||
|
// Speed Test Endpoints
|
||||||
|
speedTestEndpoints: EndpointCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexFormFields({
|
||||||
|
codexApiKey,
|
||||||
|
onApiKeyChange,
|
||||||
|
category,
|
||||||
|
shouldShowApiKeyLink,
|
||||||
|
websiteUrl,
|
||||||
|
shouldShowSpeedTest,
|
||||||
|
codexBaseUrl,
|
||||||
|
onBaseUrlChange,
|
||||||
|
isEndpointModalOpen,
|
||||||
|
onEndpointModalToggle,
|
||||||
|
onCustomEndpointsChange,
|
||||||
|
speedTestEndpoints,
|
||||||
|
}: CodexFormFieldsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Codex API Key 输入框 */}
|
||||||
|
<ApiKeySection
|
||||||
|
id="codexApiKey"
|
||||||
|
label="API Key"
|
||||||
|
value={codexApiKey}
|
||||||
|
onChange={onApiKeyChange}
|
||||||
|
category={category}
|
||||||
|
shouldShowLink={shouldShowApiKeyLink}
|
||||||
|
websiteUrl={websiteUrl}
|
||||||
|
placeholder={{
|
||||||
|
official: t("providerForm.codexOfficialNoApiKey", {
|
||||||
|
defaultValue: "官方供应商无需 API Key",
|
||||||
|
}),
|
||||||
|
thirdParty: t("providerForm.codexApiKeyAutoFill", {
|
||||||
|
defaultValue: "输入 API Key,将自动填充到配置",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Codex Base URL 输入框 */}
|
||||||
|
{shouldShowSpeedTest && (
|
||||||
|
<EndpointField
|
||||||
|
id="codexBaseUrl"
|
||||||
|
label={t("codexConfig.apiUrlLabel")}
|
||||||
|
value={codexBaseUrl}
|
||||||
|
onChange={onBaseUrlChange}
|
||||||
|
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
|
||||||
|
hint={t("providerForm.codexApiHint")}
|
||||||
|
onManageClick={() => onEndpointModalToggle(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 端点测速弹窗 - Codex */}
|
||||||
|
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||||
|
<EndpointSpeedTest
|
||||||
|
appId="codex"
|
||||||
|
value={codexBaseUrl}
|
||||||
|
onChange={onBaseUrlChange}
|
||||||
|
initialEndpoints={speedTestEndpoints}
|
||||||
|
visible={isEndpointModalOpen}
|
||||||
|
onClose={() => onEndpointModalToggle(false)}
|
||||||
|
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
src/components/providers/forms/CodexQuickWizardModal.tsx
Normal file
298
src/components/providers/forms/CodexQuickWizardModal.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import { Save } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
generateThirdPartyAuth,
|
||||||
|
generateThirdPartyConfig,
|
||||||
|
} from "@/config/codexProviderPresets";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
interface CodexQuickWizardModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (
|
||||||
|
auth: string,
|
||||||
|
config: string,
|
||||||
|
extras: {
|
||||||
|
websiteUrl?: string;
|
||||||
|
displayName?: string;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodexQuickWizardModal - Codex quick configuration wizard
|
||||||
|
* Helps users quickly generate auth.json and config.toml
|
||||||
|
*/
|
||||||
|
export const CodexQuickWizardModal: React.FC<CodexQuickWizardModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [templateApiKey, setTemplateApiKey] = useState("");
|
||||||
|
const [templateProviderName, setTemplateProviderName] = useState("");
|
||||||
|
const [templateBaseUrl, setTemplateBaseUrl] = useState("");
|
||||||
|
const [templateWebsiteUrl, setTemplateWebsiteUrl] = useState("");
|
||||||
|
const [templateModelName, setTemplateModelName] = useState("gpt-5-codex");
|
||||||
|
const [templateDisplayName, setTemplateDisplayName] = useState("");
|
||||||
|
|
||||||
|
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const baseUrlInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const modelNameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const displayNameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setTemplateApiKey("");
|
||||||
|
setTemplateProviderName("");
|
||||||
|
setTemplateBaseUrl("");
|
||||||
|
setTemplateWebsiteUrl("");
|
||||||
|
setTemplateModelName("gpt-5-codex");
|
||||||
|
setTemplateDisplayName("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTemplate = () => {
|
||||||
|
const requiredInputs = [
|
||||||
|
displayNameInputRef.current,
|
||||||
|
apiKeyInputRef.current,
|
||||||
|
baseUrlInputRef.current,
|
||||||
|
modelNameInputRef.current,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const input of requiredInputs) {
|
||||||
|
if (input && !input.checkValidity()) {
|
||||||
|
input.reportValidity();
|
||||||
|
input.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedKey = templateApiKey.trim();
|
||||||
|
const trimmedBaseUrl = templateBaseUrl.trim();
|
||||||
|
const trimmedModel = templateModelName.trim();
|
||||||
|
|
||||||
|
const auth = generateThirdPartyAuth(trimmedKey);
|
||||||
|
const config = generateThirdPartyConfig(
|
||||||
|
templateProviderName || "custom",
|
||||||
|
trimmedBaseUrl,
|
||||||
|
trimmedModel,
|
||||||
|
);
|
||||||
|
|
||||||
|
onApply(JSON.stringify(auth, null, 2), config, {
|
||||||
|
websiteUrl: templateWebsiteUrl.trim(),
|
||||||
|
displayName: templateDisplayName.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
applyTemplate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||||
|
<DialogContent
|
||||||
|
zIndex="nested"
|
||||||
|
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-0">
|
||||||
|
<DialogTitle>{t("codexConfig.quickWizard")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 space-y-4 overflow-auto px-6 py-4">
|
||||||
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
{t("codexConfig.wizardHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* API Key */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("codexConfig.apiKeyLabel")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={templateApiKey}
|
||||||
|
ref={apiKeyInputRef}
|
||||||
|
onChange={(e) => setTemplateApiKey(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
pattern=".*\S.*"
|
||||||
|
title={t("common.enterValidValue")}
|
||||||
|
placeholder={t("codexConfig.apiKeyPlaceholder")}
|
||||||
|
required
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("codexConfig.supplierNameLabel")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={templateDisplayName}
|
||||||
|
ref={displayNameInputRef}
|
||||||
|
onChange={(e) => setTemplateDisplayName(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
placeholder={t("codexConfig.supplierNamePlaceholder")}
|
||||||
|
required
|
||||||
|
pattern=".*\S.*"
|
||||||
|
title={t("common.enterValidValue")}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("codexConfig.supplierNameHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("codexConfig.supplierCodeLabel")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={templateProviderName}
|
||||||
|
onChange={(e) => setTemplateProviderName(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
placeholder={t("codexConfig.supplierCodePlaceholder")}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("codexConfig.supplierCodeHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Base URL */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("codexConfig.apiUrlLabel")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={templateBaseUrl}
|
||||||
|
ref={baseUrlInputRef}
|
||||||
|
onChange={(e) => setTemplateBaseUrl(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
placeholder={t("codexConfig.apiUrlPlaceholder")}
|
||||||
|
required
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Website URL */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("codexConfig.websiteLabel")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={templateWebsiteUrl}
|
||||||
|
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
placeholder={t("codexConfig.websitePlaceholder")}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("codexConfig.websiteHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("codexConfig.modelNameLabel")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={templateModelName}
|
||||||
|
ref={modelNameInputRef}
|
||||||
|
onChange={(e) => setTemplateModelName(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
pattern=".*\S.*"
|
||||||
|
title={t("common.enterValidValue")}
|
||||||
|
placeholder={t("codexConfig.modelNamePlaceholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{(templateApiKey || templateProviderName || templateBaseUrl) && (
|
||||||
|
<div className="space-y-2 border-t border-border-default pt-4 ">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("codexConfig.configPreview")}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
auth.json
|
||||||
|
</label>
|
||||||
|
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
{JSON.stringify(
|
||||||
|
generateThirdPartyAuth(templateApiKey),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
config.toml
|
||||||
|
</label>
|
||||||
|
<pre className="whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
{templateProviderName && templateBaseUrl
|
||||||
|
? generateThirdPartyConfig(
|
||||||
|
templateProviderName,
|
||||||
|
templateBaseUrl,
|
||||||
|
templateModelName,
|
||||||
|
)
|
||||||
|
: ""}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
applyTemplate();
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{t("codexConfig.applyConfig")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
147
src/components/providers/forms/CommonConfigEditor.tsx
Normal file
147
src/components/providers/forms/CommonConfigEditor.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
interface CommonConfigEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
useCommonConfig: boolean;
|
||||||
|
onCommonConfigToggle: (checked: boolean) => void;
|
||||||
|
commonConfigSnippet: string;
|
||||||
|
onCommonConfigSnippetChange: (value: string) => void;
|
||||||
|
commonConfigError: string;
|
||||||
|
onEditClick: () => void;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
onModalClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommonConfigEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
useCommonConfig,
|
||||||
|
onCommonConfigToggle,
|
||||||
|
commonConfigSnippet,
|
||||||
|
onCommonConfigSnippetChange,
|
||||||
|
commonConfigError,
|
||||||
|
onEditClick,
|
||||||
|
isModalOpen,
|
||||||
|
onModalClose,
|
||||||
|
}: CommonConfigEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="useCommonConfig"
|
||||||
|
checked={useCommonConfig}
|
||||||
|
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t("claudeConfig.writeCommonConfig", {
|
||||||
|
defaultValue: "写入通用配置",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditClick}
|
||||||
|
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
{t("claudeConfig.editCommonConfig", {
|
||||||
|
defaultValue: "编辑通用配置",
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{commonConfigError && !isModalOpen && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||||
|
{commonConfigError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
id="settingsConfig"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={`{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
rows={14}
|
||||||
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[16rem]"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("claudeConfig.fullSettingsHint", {
|
||||||
|
defaultValue: "请填写完整的 Claude Code 配置",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isModalOpen}
|
||||||
|
onOpenChange={(open) => !open && onModalClose()}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("claudeConfig.editCommonConfigTitle", {
|
||||||
|
defaultValue: "编辑通用配置片段",
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("claudeConfig.commonConfigHint", {
|
||||||
|
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={commonConfigSnippet}
|
||||||
|
onChange={(e) => onCommonConfigSnippetChange(e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[14rem]"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
|
/>
|
||||||
|
{commonConfigError && (
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-400">
|
||||||
|
{commonConfigError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,34 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Zap, Loader2, Plus, X, AlertCircle, Save } from "lucide-react";
|
import { Zap, Loader2, Plus, X, AlertCircle, Save } from "lucide-react";
|
||||||
import { isLinux } from "../../lib/platform";
|
import type { AppId } from "@/lib/api";
|
||||||
|
import { vscodeApi } from "@/lib/api/vscode";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
||||||
|
|
||||||
import type { AppType } from "../../lib/tauri-api";
|
// 端点测速超时配置(秒)
|
||||||
|
const ENDPOINT_TIMEOUT_SECS = {
|
||||||
|
codex: 12,
|
||||||
|
claude: 8,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface EndpointCandidate {
|
interface TestResult {
|
||||||
id?: string;
|
|
||||||
url: string;
|
url: string;
|
||||||
isCustom?: boolean;
|
latency: number | null;
|
||||||
|
status?: number;
|
||||||
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EndpointSpeedTestProps {
|
interface EndpointSpeedTestProps {
|
||||||
appType: AppType;
|
appId: AppId;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (url: string) => void;
|
onChange: (url: string) => void;
|
||||||
@@ -66,7 +82,7 @@ const buildInitialEntries = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||||
appType,
|
appId,
|
||||||
providerId,
|
providerId,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -91,17 +107,26 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
|
|
||||||
// 加载保存的自定义端点(按正在编辑的供应商)
|
// 加载保存的自定义端点(按正在编辑的供应商)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const loadCustomEndpoints = async () => {
|
const loadCustomEndpoints = async () => {
|
||||||
try {
|
try {
|
||||||
if (!providerId) return;
|
if (!providerId) return;
|
||||||
const customEndpoints = await window.api.getCustomEndpoints(
|
|
||||||
appType,
|
const customEndpoints = await vscodeApi.getCustomEndpoints(
|
||||||
|
appId,
|
||||||
providerId,
|
providerId,
|
||||||
);
|
);
|
||||||
const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({
|
|
||||||
url: ep.url,
|
// 检查是否已取消
|
||||||
isCustom: true,
|
if (cancelled) return;
|
||||||
}));
|
|
||||||
|
const candidates: EndpointCandidate[] = customEndpoints.map(
|
||||||
|
(ep: CustomEndpoint) => ({
|
||||||
|
url: ep.url,
|
||||||
|
isCustom: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
const map = new Map<string, EndpointEntry>();
|
const map = new Map<string, EndpointEntry>();
|
||||||
@@ -129,14 +154,20 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t("endpointTest.loadEndpointsFailed"), error);
|
if (!cancelled) {
|
||||||
|
console.error(t("endpointTest.loadEndpointsFailed"), error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (visible) {
|
if (visible) {
|
||||||
loadCustomEndpoints();
|
loadCustomEndpoints();
|
||||||
}
|
}
|
||||||
}, [appType, visible, providerId, t]);
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [appId, visible, providerId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
@@ -200,7 +231,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
}, [entries, onCustomEndpointsChange]);
|
}, [entries, onCustomEndpointsChange]);
|
||||||
|
|
||||||
const sortedEntries = useMemo(() => {
|
const sortedEntries = useMemo(() => {
|
||||||
return entries.slice().sort((a, b) => {
|
return entries.slice().sort((a: TestResult, b: TestResult) => {
|
||||||
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
|
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
|
||||||
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
|
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
|
||||||
if (aLatency === bLatency) {
|
if (aLatency === bLatency) {
|
||||||
@@ -227,7 +258,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
|
// 明确只允许 http: 和 https:
|
||||||
|
const allowedProtocols = ["http:", "https:"];
|
||||||
|
if (!errorMsg && parsed && !allowedProtocols.includes(parsed.protocol)) {
|
||||||
errorMsg = t("endpointTest.onlyHttps");
|
errorMsg = t("endpointTest.onlyHttps");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +284,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
// 保存到后端
|
// 保存到后端
|
||||||
try {
|
try {
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
await window.api.addCustomEndpoint(appType, providerId, sanitized);
|
await vscodeApi.addCustomEndpoint(appId, providerId, sanitized);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新本地状态
|
// 更新本地状态
|
||||||
@@ -280,29 +313,38 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
setAddError(message || t("endpointTest.saveFailed"));
|
setAddError(message || t("endpointTest.saveFailed"));
|
||||||
console.error(t("endpointTest.addEndpointFailed"), error);
|
console.error(t("endpointTest.addEndpointFailed"), error);
|
||||||
}
|
}
|
||||||
}, [
|
}, [customUrl, entries, normalizedSelected, onChange, appId, providerId, t]);
|
||||||
customUrl,
|
|
||||||
entries,
|
|
||||||
normalizedSelected,
|
|
||||||
onChange,
|
|
||||||
appType,
|
|
||||||
providerId,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRemoveEndpoint = useCallback(
|
const handleRemoveEndpoint = useCallback(
|
||||||
async (entry: EndpointEntry) => {
|
async (entry: EndpointEntry) => {
|
||||||
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除)
|
// 清空之前的错误提示
|
||||||
|
setLastError(null);
|
||||||
|
|
||||||
|
// 如果有 providerId,尝试从后端删除
|
||||||
if (entry.isCustom && providerId) {
|
if (entry.isCustom && providerId) {
|
||||||
try {
|
try {
|
||||||
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
|
await vscodeApi.removeCustomEndpoint(appId, providerId, entry.url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t("endpointTest.removeEndpointFailed"), error);
|
const errorMsg =
|
||||||
return;
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// 只有"端点不存在"时才允许删除本地条目
|
||||||
|
if (
|
||||||
|
errorMsg.includes("not found") ||
|
||||||
|
errorMsg.includes("does not exist") ||
|
||||||
|
errorMsg.includes("不存在")
|
||||||
|
) {
|
||||||
|
console.warn(t("endpointTest.removeEndpointFailed"), errorMsg);
|
||||||
|
// 继续删除本地条目
|
||||||
|
} else {
|
||||||
|
// 其他错误:显示错误提示,阻止删除
|
||||||
|
setLastError(t("endpointTest.removeFailed", { error: errorMsg }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新本地状态
|
// 更新本地状态(删除成功)
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
const next = prev.filter((item) => item.id !== entry.id);
|
const next = prev.filter((item) => item.id !== entry.id);
|
||||||
if (entry.url === normalizedSelected) {
|
if (entry.url === normalizedSelected) {
|
||||||
@@ -312,7 +354,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[normalizedSelected, onChange, appType, providerId, t],
|
[normalizedSelected, onChange, appId, providerId, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runSpeedTest = useCallback(async () => {
|
const runSpeedTest = useCallback(async () => {
|
||||||
@@ -322,11 +364,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
|
|
||||||
setLastError(t("endpointTest.testUnavailable"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
setLastError(null);
|
setLastError(null);
|
||||||
|
|
||||||
@@ -341,9 +378,10 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await window.api.testApiEndpoints(urls, {
|
const results = await vscodeApi.testApiEndpoints(urls, {
|
||||||
timeoutSecs: appType === "codex" ? 12 : 8,
|
timeoutSecs: ENDPOINT_TIMEOUT_SECS[appId],
|
||||||
});
|
});
|
||||||
|
|
||||||
const resultMap = new Map(
|
const resultMap = new Map(
|
||||||
results.map((item) => [normalizeEndpointUrl(item.url), item]),
|
results.map((item) => [normalizeEndpointUrl(item.url), item]),
|
||||||
);
|
);
|
||||||
@@ -391,7 +429,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
}
|
}
|
||||||
}, [entries, autoSelect, appType, normalizedSelected, onChange, t]);
|
}, [entries, autoSelect, appId, normalizedSelected, onChange, t]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
@@ -400,60 +438,27 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
// 更新最后使用时间(对自定义端点)
|
// 更新最后使用时间(对自定义端点)
|
||||||
const entry = entries.find((e) => e.url === url);
|
const entry = entries.find((e) => e.url === url);
|
||||||
if (entry?.isCustom && providerId) {
|
if (entry?.isCustom && providerId) {
|
||||||
await window.api.updateEndpointLastUsed(appType, providerId, url);
|
try {
|
||||||
|
await vscodeApi.updateEndpointLastUsed(appId, providerId, url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(t("endpointTest.updateLastUsedFailed"), error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(url);
|
onChange(url);
|
||||||
},
|
},
|
||||||
[normalizedSelected, onChange, appType, entries, providerId],
|
[normalizedSelected, onChange, appId, entries, providerId, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 支持按下 ESC 关闭弹窗
|
|
||||||
useEffect(() => {
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
if (!visible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
<DialogContent
|
||||||
onMouseDown={(e) => {
|
zIndex="nested"
|
||||||
if (e.target === e.currentTarget) onClose();
|
className="max-w-2xl max-h-[80vh] flex flex-col p-0"
|
||||||
}}
|
>
|
||||||
>
|
<DialogHeader className="px-6 pt-6 pb-0">
|
||||||
{/* Backdrop */}
|
<DialogTitle>{t("endpointTest.title")}</DialogTitle>
|
||||||
<div
|
</DialogHeader>
|
||||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
|
||||||
isLinux() ? "" : " backdrop-blur-sm"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
|
||||||
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("endpointTest.title")}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||||
@@ -468,15 +473,16 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoSelect}
|
checked={autoSelect}
|
||||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||||
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
|
className="h-3.5 w-3.5 rounded border-border-default "
|
||||||
/>
|
/>
|
||||||
{t("endpointTest.autoSelect")}
|
{t("endpointTest.autoSelect")}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={runSpeedTest}
|
onClick={runSpeedTest}
|
||||||
disabled={isTesting || !hasEndpoints}
|
disabled={isTesting || !hasEndpoints}
|
||||||
className="flex h-7 w-20 items-center justify-center gap-1.5 rounded-md bg-blue-500 px-2.5 text-xs font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-600 dark:hover:bg-blue-700"
|
size="sm"
|
||||||
|
className="h-7 w-20 gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
{isTesting ? (
|
{isTesting ? (
|
||||||
<>
|
<>
|
||||||
@@ -489,14 +495,14 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
{t("endpointTest.testSpeed")}
|
{t("endpointTest.testSpeed")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 添加输入 */}
|
{/* 添加输入 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
value={customUrl}
|
value={customUrl}
|
||||||
placeholder={t("endpointTest.addEndpointPlaceholder")}
|
placeholder={t("endpointTest.addEndpointPlaceholder")}
|
||||||
@@ -507,15 +513,16 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
handleAddEndpoint();
|
handleAddEndpoint();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 transition focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-gray-600"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddEndpoint}
|
onClick={handleAddEndpoint}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800"
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{addError && (
|
{addError && (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||||
@@ -539,7 +546,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
|
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
|
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
|
||||||
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-850"
|
: "border-border-default bg-white hover:border-border-default hover:bg-gray-50 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
@@ -604,7 +611,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 py-8 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
|
<div className="rounded-md border border-dashed border-border-default bg-gray-50 py-8 text-center text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||||
{t("endpointTest.noEndpoints")}
|
{t("endpointTest.noEndpoints")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -618,19 +625,14 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
<DialogFooter>
|
||||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
<Button type="button" onClick={onClose} className="gap-2">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4" />
|
||||||
{t("common.save")}
|
{t("common.save")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,8 +98,8 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
|
|
||||||
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
|
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
|
||||||
disabled
|
disabled
|
||||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
? "bg-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||||
: "border-gray-200 dark:border-gray-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
: "border-border-default dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active "
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const ModelSelect: React.FC<{
|
const ModelSelect: React.FC<{
|
||||||
582
src/components/providers/forms/ProviderForm.tsx
Normal file
582
src/components/providers/forms/ProviderForm.tsx
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Form } from "@/components/ui/form";
|
||||||
|
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||||
|
import type { AppId } from "@/lib/api";
|
||||||
|
import type { ProviderCategory, ProviderMeta } from "@/types";
|
||||||
|
import { providerPresets, type ProviderPreset } from "@/config/providerPresets";
|
||||||
|
import {
|
||||||
|
codexProviderPresets,
|
||||||
|
type CodexProviderPreset,
|
||||||
|
} from "@/config/codexProviderPresets";
|
||||||
|
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||||
|
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||||
|
import CodexConfigEditor from "./CodexConfigEditor";
|
||||||
|
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||||
|
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||||
|
import { BasicFormFields } from "./BasicFormFields";
|
||||||
|
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||||
|
import { CodexFormFields } from "./CodexFormFields";
|
||||||
|
import {
|
||||||
|
useProviderCategory,
|
||||||
|
useApiKeyState,
|
||||||
|
useBaseUrlState,
|
||||||
|
useModelState,
|
||||||
|
useCodexConfigState,
|
||||||
|
useApiKeyLink,
|
||||||
|
useCustomEndpoints,
|
||||||
|
useKimiModelSelector,
|
||||||
|
useTemplateValues,
|
||||||
|
useCommonConfigSnippet,
|
||||||
|
useCodexCommonConfig,
|
||||||
|
useSpeedTestEndpoints,
|
||||||
|
useCodexTomlValidation,
|
||||||
|
} from "./hooks";
|
||||||
|
|
||||||
|
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||||
|
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||||
|
|
||||||
|
type PresetEntry = {
|
||||||
|
id: string;
|
||||||
|
preset: ProviderPreset | CodexProviderPreset;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProviderFormProps {
|
||||||
|
appId: AppId;
|
||||||
|
submitLabel: string;
|
||||||
|
onSubmit: (values: ProviderFormValues) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
initialData?: {
|
||||||
|
name?: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
category?: ProviderCategory;
|
||||||
|
meta?: ProviderMeta;
|
||||||
|
};
|
||||||
|
showButtons?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderForm({
|
||||||
|
appId,
|
||||||
|
submitLabel,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
initialData,
|
||||||
|
showButtons = true,
|
||||||
|
}: ProviderFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isEditMode = Boolean(initialData);
|
||||||
|
|
||||||
|
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
|
||||||
|
initialData ? null : "custom",
|
||||||
|
);
|
||||||
|
const [activePreset, setActivePreset] = useState<{
|
||||||
|
id: string;
|
||||||
|
category?: ProviderCategory;
|
||||||
|
} | null>(null);
|
||||||
|
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 新建供应商:收集端点测速弹窗中的"自定义端点",提交时一次性落盘到 meta.custom_endpoints
|
||||||
|
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用 category hook
|
||||||
|
const { category } = useProviderCategory({
|
||||||
|
appId,
|
||||||
|
selectedPresetId,
|
||||||
|
isEditMode,
|
||||||
|
initialCategory: initialData?.category,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedPresetId(initialData ? null : "custom");
|
||||||
|
setActivePreset(null);
|
||||||
|
}, [appId, initialData]);
|
||||||
|
|
||||||
|
const defaultValues: ProviderFormData = useMemo(
|
||||||
|
() => ({
|
||||||
|
name: initialData?.name ?? "",
|
||||||
|
websiteUrl: initialData?.websiteUrl ?? "",
|
||||||
|
settingsConfig: initialData?.settingsConfig
|
||||||
|
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||||
|
: appId === "codex"
|
||||||
|
? CODEX_DEFAULT_CONFIG
|
||||||
|
: CLAUDE_DEFAULT_CONFIG,
|
||||||
|
}),
|
||||||
|
[initialData, appId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<ProviderFormData>({
|
||||||
|
resolver: zodResolver(providerSchema),
|
||||||
|
defaultValues,
|
||||||
|
mode: "onSubmit",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 API Key hook
|
||||||
|
const {
|
||||||
|
apiKey,
|
||||||
|
handleApiKeyChange,
|
||||||
|
showApiKey: shouldShowApiKey,
|
||||||
|
} = useApiKeyState({
|
||||||
|
initialConfig: form.watch("settingsConfig"),
|
||||||
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
|
selectedPresetId,
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 Base URL hook (仅 Claude 模式)
|
||||||
|
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||||||
|
appType: appId,
|
||||||
|
category,
|
||||||
|
settingsConfig: form.watch("settingsConfig"),
|
||||||
|
codexConfig: "",
|
||||||
|
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
|
onCodexConfigChange: () => {
|
||||||
|
// Codex 使用 useCodexConfigState 管理 Base URL
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 Model hook
|
||||||
|
const { claudeModel, claudeSmallFastModel, handleModelChange } =
|
||||||
|
useModelState({
|
||||||
|
settingsConfig: form.watch("settingsConfig"),
|
||||||
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 Codex 配置 hook (仅 Codex 模式)
|
||||||
|
const {
|
||||||
|
codexAuth,
|
||||||
|
codexConfig,
|
||||||
|
codexApiKey,
|
||||||
|
codexBaseUrl,
|
||||||
|
codexAuthError,
|
||||||
|
setCodexAuth,
|
||||||
|
handleCodexApiKeyChange,
|
||||||
|
handleCodexBaseUrlChange,
|
||||||
|
handleCodexConfigChange: originalHandleCodexConfigChange,
|
||||||
|
resetCodexConfig,
|
||||||
|
} = useCodexConfigState({ initialData });
|
||||||
|
|
||||||
|
// 使用 Codex TOML 校验 hook (仅 Codex 模式)
|
||||||
|
const { configError: codexConfigError, debouncedValidate } =
|
||||||
|
useCodexTomlValidation();
|
||||||
|
|
||||||
|
// 包装 handleCodexConfigChange,添加实时校验
|
||||||
|
const handleCodexConfigChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
originalHandleCodexConfigChange(value);
|
||||||
|
debouncedValidate(value);
|
||||||
|
},
|
||||||
|
[originalHandleCodexConfigChange, debouncedValidate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(defaultValues);
|
||||||
|
}, [defaultValues, form]);
|
||||||
|
|
||||||
|
const presetCategoryLabels: Record<string, string> = useMemo(
|
||||||
|
() => ({
|
||||||
|
official: t("providerPreset.categoryOfficial", {
|
||||||
|
defaultValue: "官方",
|
||||||
|
}),
|
||||||
|
cn_official: t("providerPreset.categoryCnOfficial", {
|
||||||
|
defaultValue: "国内官方",
|
||||||
|
}),
|
||||||
|
aggregator: t("providerPreset.categoryAggregator", {
|
||||||
|
defaultValue: "聚合服务",
|
||||||
|
}),
|
||||||
|
third_party: t("providerPreset.categoryThirdParty", {
|
||||||
|
defaultValue: "第三方",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const presetEntries = useMemo(() => {
|
||||||
|
if (appId === "codex") {
|
||||||
|
return codexProviderPresets.map<PresetEntry>((preset, index) => ({
|
||||||
|
id: `codex-${index}`,
|
||||||
|
preset,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||||||
|
id: `claude-${index}`,
|
||||||
|
preset,
|
||||||
|
}));
|
||||||
|
}, [appId]);
|
||||||
|
|
||||||
|
// 使用 Kimi 模型选择器 hook
|
||||||
|
const {
|
||||||
|
shouldShow: shouldShowKimiSelector,
|
||||||
|
kimiAnthropicModel,
|
||||||
|
kimiAnthropicSmallFastModel,
|
||||||
|
handleKimiModelChange,
|
||||||
|
} = useKimiModelSelector({
|
||||||
|
initialData,
|
||||||
|
settingsConfig: form.watch("settingsConfig"),
|
||||||
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
|
selectedPresetId,
|
||||||
|
presetName:
|
||||||
|
selectedPresetId && selectedPresetId !== "custom"
|
||||||
|
? presetEntries.find((item) => item.id === selectedPresetId)?.preset
|
||||||
|
.name || ""
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用模板变量 hook (仅 Claude 模式)
|
||||||
|
const {
|
||||||
|
templateValues,
|
||||||
|
templateValueEntries,
|
||||||
|
selectedPreset: templatePreset,
|
||||||
|
handleTemplateValueChange,
|
||||||
|
validateTemplateValues,
|
||||||
|
} = useTemplateValues({
|
||||||
|
selectedPresetId: appId === "claude" ? selectedPresetId : null,
|
||||||
|
presetEntries: appId === "claude" ? presetEntries : [],
|
||||||
|
settingsConfig: form.watch("settingsConfig"),
|
||||||
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用通用配置片段 hook (仅 Claude 模式)
|
||||||
|
const {
|
||||||
|
useCommonConfig,
|
||||||
|
commonConfigSnippet,
|
||||||
|
commonConfigError,
|
||||||
|
handleCommonConfigToggle,
|
||||||
|
handleCommonConfigSnippetChange,
|
||||||
|
} = useCommonConfigSnippet({
|
||||||
|
settingsConfig: form.watch("settingsConfig"),
|
||||||
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
|
initialData: appId === "claude" ? initialData : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
|
||||||
|
const {
|
||||||
|
useCommonConfig: useCodexCommonConfigFlag,
|
||||||
|
commonConfigSnippet: codexCommonConfigSnippet,
|
||||||
|
commonConfigError: codexCommonConfigError,
|
||||||
|
handleCommonConfigToggle: handleCodexCommonConfigToggle,
|
||||||
|
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
|
||||||
|
} = useCodexCommonConfig({
|
||||||
|
codexConfig,
|
||||||
|
onConfigChange: handleCodexConfigChange,
|
||||||
|
initialData: appId === "codex" ? initialData : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = (values: ProviderFormData) => {
|
||||||
|
// 验证模板变量(仅 Claude 模式)
|
||||||
|
if (appId === "claude" && templateValueEntries.length > 0) {
|
||||||
|
const validation = validateTemplateValues();
|
||||||
|
if (!validation.isValid && validation.missingField) {
|
||||||
|
form.setError("settingsConfig", {
|
||||||
|
type: "manual",
|
||||||
|
message: t("providerForm.fillParameter", {
|
||||||
|
label: validation.missingField.label,
|
||||||
|
defaultValue: `请填写 ${validation.missingField.label}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let settingsConfig: string;
|
||||||
|
|
||||||
|
// Codex: 组合 auth 和 config
|
||||||
|
if (appId === "codex") {
|
||||||
|
try {
|
||||||
|
const authJson = JSON.parse(codexAuth);
|
||||||
|
const configObj = {
|
||||||
|
auth: authJson,
|
||||||
|
config: codexConfig ?? "",
|
||||||
|
};
|
||||||
|
settingsConfig = JSON.stringify(configObj);
|
||||||
|
} catch (err) {
|
||||||
|
// 如果解析失败,使用表单中的配置
|
||||||
|
settingsConfig = values.settingsConfig.trim();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Claude: 使用表单配置
|
||||||
|
settingsConfig = values.settingsConfig.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ProviderFormValues = {
|
||||||
|
...values,
|
||||||
|
name: values.name.trim(),
|
||||||
|
websiteUrl: values.websiteUrl?.trim() ?? "",
|
||||||
|
settingsConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activePreset) {
|
||||||
|
payload.presetId = activePreset.id;
|
||||||
|
if (activePreset.category) {
|
||||||
|
payload.presetCategory = activePreset.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 meta 字段(新建与编辑使用不同策略)
|
||||||
|
const mergedMeta = mergeProviderMeta(initialData?.meta, customEndpointsMap);
|
||||||
|
if (mergedMeta) {
|
||||||
|
payload.meta = mergedMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedPresets = useMemo(() => {
|
||||||
|
return presetEntries.reduce<Record<string, PresetEntry[]>>((acc, entry) => {
|
||||||
|
const category = entry.preset.category ?? "others";
|
||||||
|
if (!acc[category]) {
|
||||||
|
acc[category] = [];
|
||||||
|
}
|
||||||
|
acc[category].push(entry);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}, [presetEntries]);
|
||||||
|
|
||||||
|
const categoryKeys = useMemo(() => {
|
||||||
|
return Object.keys(groupedPresets).filter(
|
||||||
|
(key) => key !== "custom" && groupedPresets[key]?.length,
|
||||||
|
);
|
||||||
|
}, [groupedPresets]);
|
||||||
|
|
||||||
|
// 判断是否显示端点测速(仅第三方和自定义类别)
|
||||||
|
const shouldShowSpeedTest =
|
||||||
|
category === "third_party" || category === "custom";
|
||||||
|
|
||||||
|
// 使用 API Key 链接 hook (Claude)
|
||||||
|
const {
|
||||||
|
shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,
|
||||||
|
websiteUrl: claudeWebsiteUrl,
|
||||||
|
} = useApiKeyLink({
|
||||||
|
appId: "claude",
|
||||||
|
category,
|
||||||
|
selectedPresetId,
|
||||||
|
presetEntries,
|
||||||
|
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 API Key 链接 hook (Codex)
|
||||||
|
const {
|
||||||
|
shouldShowApiKeyLink: shouldShowCodexApiKeyLink,
|
||||||
|
websiteUrl: codexWebsiteUrl,
|
||||||
|
} = useApiKeyLink({
|
||||||
|
appId: "codex",
|
||||||
|
category,
|
||||||
|
selectedPresetId,
|
||||||
|
presetEntries,
|
||||||
|
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用自定义端点 hook
|
||||||
|
const customEndpointsMap = useCustomEndpoints({
|
||||||
|
appId,
|
||||||
|
selectedPresetId,
|
||||||
|
presetEntries,
|
||||||
|
draftCustomEndpoints,
|
||||||
|
baseUrl,
|
||||||
|
codexBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用端点测速候选 hook
|
||||||
|
const speedTestEndpoints = useSpeedTestEndpoints({
|
||||||
|
appId,
|
||||||
|
selectedPresetId,
|
||||||
|
presetEntries,
|
||||||
|
baseUrl,
|
||||||
|
codexBaseUrl,
|
||||||
|
initialData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePresetChange = (value: string) => {
|
||||||
|
setSelectedPresetId(value);
|
||||||
|
if (value === "custom") {
|
||||||
|
setActivePreset(null);
|
||||||
|
form.reset(defaultValues);
|
||||||
|
|
||||||
|
// Codex 自定义模式:重置为空配置
|
||||||
|
if (appId === "codex") {
|
||||||
|
resetCodexConfig({}, "");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = presetEntries.find((item) => item.id === value);
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivePreset({
|
||||||
|
id: value,
|
||||||
|
category: entry.preset.category,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (appId === "codex") {
|
||||||
|
const preset = entry.preset as CodexProviderPreset;
|
||||||
|
const auth = preset.auth ?? {};
|
||||||
|
const config = preset.config ?? "";
|
||||||
|
|
||||||
|
// 重置 Codex 配置
|
||||||
|
resetCodexConfig(auth, config);
|
||||||
|
|
||||||
|
// 更新表单其他字段
|
||||||
|
form.reset({
|
||||||
|
name: preset.name,
|
||||||
|
websiteUrl: preset.websiteUrl ?? "",
|
||||||
|
settingsConfig: JSON.stringify({ auth, config }, null, 2),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = entry.preset as ProviderPreset;
|
||||||
|
const config = applyTemplateValues(
|
||||||
|
preset.settingsConfig,
|
||||||
|
preset.templateValues,
|
||||||
|
);
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: preset.name,
|
||||||
|
websiteUrl: preset.websiteUrl ?? "",
|
||||||
|
settingsConfig: JSON.stringify(config, null, 2),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="provider-form"
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* 预设供应商选择(仅新增模式显示) */}
|
||||||
|
{!initialData && (
|
||||||
|
<ProviderPresetSelector
|
||||||
|
selectedPresetId={selectedPresetId}
|
||||||
|
groupedPresets={groupedPresets}
|
||||||
|
categoryKeys={categoryKeys}
|
||||||
|
presetCategoryLabels={presetCategoryLabels}
|
||||||
|
onPresetChange={handlePresetChange}
|
||||||
|
category={category}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 基础字段 */}
|
||||||
|
<BasicFormFields form={form} />
|
||||||
|
|
||||||
|
{/* Claude 专属字段 */}
|
||||||
|
{appId === "claude" && (
|
||||||
|
<ClaudeFormFields
|
||||||
|
shouldShowApiKey={shouldShowApiKey(
|
||||||
|
form.watch("settingsConfig"),
|
||||||
|
isEditMode,
|
||||||
|
)}
|
||||||
|
apiKey={apiKey}
|
||||||
|
onApiKeyChange={handleApiKeyChange}
|
||||||
|
category={category}
|
||||||
|
shouldShowApiKeyLink={shouldShowClaudeApiKeyLink}
|
||||||
|
websiteUrl={claudeWebsiteUrl}
|
||||||
|
templateValueEntries={templateValueEntries}
|
||||||
|
templateValues={templateValues}
|
||||||
|
templatePresetName={templatePreset?.name || ""}
|
||||||
|
onTemplateValueChange={handleTemplateValueChange}
|
||||||
|
shouldShowSpeedTest={shouldShowSpeedTest}
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
onBaseUrlChange={handleClaudeBaseUrlChange}
|
||||||
|
isEndpointModalOpen={isEndpointModalOpen}
|
||||||
|
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||||
|
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||||
|
shouldShowKimiSelector={shouldShowKimiSelector}
|
||||||
|
shouldShowModelSelector={
|
||||||
|
category !== "official" && !shouldShowKimiSelector
|
||||||
|
}
|
||||||
|
claudeModel={claudeModel}
|
||||||
|
claudeSmallFastModel={claudeSmallFastModel}
|
||||||
|
onModelChange={handleModelChange}
|
||||||
|
kimiAnthropicModel={kimiAnthropicModel}
|
||||||
|
kimiAnthropicSmallFastModel={kimiAnthropicSmallFastModel}
|
||||||
|
onKimiModelChange={handleKimiModelChange}
|
||||||
|
speedTestEndpoints={speedTestEndpoints}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Codex 专属字段 */}
|
||||||
|
{appId === "codex" && (
|
||||||
|
<CodexFormFields
|
||||||
|
codexApiKey={codexApiKey}
|
||||||
|
onApiKeyChange={handleCodexApiKeyChange}
|
||||||
|
category={category}
|
||||||
|
shouldShowApiKeyLink={shouldShowCodexApiKeyLink}
|
||||||
|
websiteUrl={codexWebsiteUrl}
|
||||||
|
shouldShowSpeedTest={shouldShowSpeedTest}
|
||||||
|
codexBaseUrl={codexBaseUrl}
|
||||||
|
onBaseUrlChange={handleCodexBaseUrlChange}
|
||||||
|
isEndpointModalOpen={isCodexEndpointModalOpen}
|
||||||
|
onEndpointModalToggle={setIsCodexEndpointModalOpen}
|
||||||
|
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||||
|
speedTestEndpoints={speedTestEndpoints}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 配置编辑器:Claude 使用通用配置编辑器,Codex 使用专用编辑器 */}
|
||||||
|
{appId === "codex" ? (
|
||||||
|
<CodexConfigEditor
|
||||||
|
authValue={codexAuth}
|
||||||
|
configValue={codexConfig}
|
||||||
|
onAuthChange={setCodexAuth}
|
||||||
|
onConfigChange={handleCodexConfigChange}
|
||||||
|
useCommonConfig={useCodexCommonConfigFlag}
|
||||||
|
onCommonConfigToggle={handleCodexCommonConfigToggle}
|
||||||
|
commonConfigSnippet={codexCommonConfigSnippet}
|
||||||
|
onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange}
|
||||||
|
commonConfigError={codexCommonConfigError}
|
||||||
|
authError={codexAuthError}
|
||||||
|
configError={codexConfigError}
|
||||||
|
isCustomMode={selectedPresetId === "custom"}
|
||||||
|
onWebsiteUrlChange={(url) => form.setValue("websiteUrl", url)}
|
||||||
|
onNameChange={(name) => form.setValue("name", name)}
|
||||||
|
isTemplateModalOpen={isCodexTemplateModalOpen}
|
||||||
|
setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CommonConfigEditor
|
||||||
|
value={form.watch("settingsConfig")}
|
||||||
|
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||||
|
useCommonConfig={useCommonConfig}
|
||||||
|
onCommonConfigToggle={handleCommonConfigToggle}
|
||||||
|
commonConfigSnippet={commonConfigSnippet}
|
||||||
|
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
|
||||||
|
commonConfigError={commonConfigError}
|
||||||
|
onEditClick={() => setIsCommonConfigModalOpen(true)}
|
||||||
|
isModalOpen={isCommonConfigModalOpen}
|
||||||
|
onModalClose={() => setIsCommonConfigModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showButtons && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" type="button" onClick={onCancel}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{submitLabel}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderFormValues = ProviderFormData & {
|
||||||
|
presetId?: string;
|
||||||
|
presetCategory?: ProviderCategory;
|
||||||
|
meta?: ProviderMeta;
|
||||||
|
};
|
||||||
162
src/components/providers/forms/ProviderPresetSelector.tsx
Normal file
162
src/components/providers/forms/ProviderPresetSelector.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FormLabel } from "@/components/ui/form";
|
||||||
|
import { ClaudeIcon, CodexIcon } from "@/components/BrandIcons";
|
||||||
|
import { Zap } from "lucide-react";
|
||||||
|
import type { ProviderPreset } from "@/config/providerPresets";
|
||||||
|
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||||
|
import type { ProviderCategory } from "@/types";
|
||||||
|
|
||||||
|
type PresetEntry = {
|
||||||
|
id: string;
|
||||||
|
preset: ProviderPreset | CodexProviderPreset;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProviderPresetSelectorProps {
|
||||||
|
selectedPresetId: string | null;
|
||||||
|
groupedPresets: Record<string, PresetEntry[]>;
|
||||||
|
categoryKeys: string[];
|
||||||
|
presetCategoryLabels: Record<string, string>;
|
||||||
|
onPresetChange: (value: string) => void;
|
||||||
|
category?: ProviderCategory; // 新增:当前选中的分类
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderPresetSelector({
|
||||||
|
selectedPresetId,
|
||||||
|
groupedPresets,
|
||||||
|
categoryKeys,
|
||||||
|
presetCategoryLabels,
|
||||||
|
onPresetChange,
|
||||||
|
category,
|
||||||
|
}: ProviderPresetSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 根据分类获取提示文字
|
||||||
|
const getCategoryHint = () => {
|
||||||
|
switch (category) {
|
||||||
|
case "official":
|
||||||
|
return t("providerForm.officialHint", {
|
||||||
|
defaultValue: "💡 官方供应商使用浏览器登录,无需配置 API Key",
|
||||||
|
});
|
||||||
|
case "cn_official":
|
||||||
|
return t("providerForm.cnOfficialApiKeyHint", {
|
||||||
|
defaultValue: "💡 国产官方供应商只需填写 API Key,请求地址已预设",
|
||||||
|
});
|
||||||
|
case "aggregator":
|
||||||
|
return t("providerForm.aggregatorApiKeyHint", {
|
||||||
|
defaultValue: "💡 聚合服务供应商只需填写 API Key 即可使用",
|
||||||
|
});
|
||||||
|
case "third_party":
|
||||||
|
return t("providerForm.thirdPartyApiKeyHint", {
|
||||||
|
defaultValue: "💡 第三方供应商需要填写 API Key 和请求地址",
|
||||||
|
});
|
||||||
|
case "custom":
|
||||||
|
return t("providerForm.customApiKeyHint", {
|
||||||
|
defaultValue: "💡 自定义配置需手动填写所有必要字段",
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return t("providerPreset.hint", {
|
||||||
|
defaultValue: "选择预设后可继续调整下方字段。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染预设按钮的图标
|
||||||
|
const renderPresetIcon = (preset: ProviderPreset | CodexProviderPreset) => {
|
||||||
|
const iconType = preset.theme?.icon;
|
||||||
|
if (!iconType) return null;
|
||||||
|
|
||||||
|
switch (iconType) {
|
||||||
|
case "claude":
|
||||||
|
return <ClaudeIcon size={14} />;
|
||||||
|
case "codex":
|
||||||
|
return <CodexIcon size={14} />;
|
||||||
|
case "generic":
|
||||||
|
return <Zap size={14} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取预设按钮的样式类名
|
||||||
|
const getPresetButtonClass = (
|
||||||
|
isSelected: boolean,
|
||||||
|
preset: ProviderPreset | CodexProviderPreset,
|
||||||
|
) => {
|
||||||
|
const baseClass =
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
// 如果有自定义主题,使用自定义颜色
|
||||||
|
if (preset.theme?.backgroundColor) {
|
||||||
|
return `${baseClass} text-white`;
|
||||||
|
}
|
||||||
|
// 默认使用主题蓝色
|
||||||
|
return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取预设按钮的内联样式(用于自定义背景色)
|
||||||
|
const getPresetButtonStyle = (
|
||||||
|
isSelected: boolean,
|
||||||
|
preset: ProviderPreset | CodexProviderPreset,
|
||||||
|
) => {
|
||||||
|
if (!isSelected || !preset.theme?.backgroundColor) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: preset.theme.backgroundColor,
|
||||||
|
color: preset.theme.textColor || "#FFFFFF",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FormLabel>{t("providerPreset.label")}</FormLabel>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* 自定义按钮 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPresetChange("custom")}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
selectedPresetId === "custom"
|
||||||
|
? "bg-blue-500 text-white dark:bg-blue-600"
|
||||||
|
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("providerPreset.custom")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 预设按钮 */}
|
||||||
|
{categoryKeys.map((category) => {
|
||||||
|
const entries = groupedPresets[category];
|
||||||
|
if (!entries || entries.length === 0) return null;
|
||||||
|
return entries.map((entry) => {
|
||||||
|
const isSelected = selectedPresetId === entry.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPresetChange(entry.id)}
|
||||||
|
className={getPresetButtonClass(isSelected, entry.preset)}
|
||||||
|
style={getPresetButtonStyle(isSelected, entry.preset)}
|
||||||
|
title={
|
||||||
|
presetCategoryLabels[category] ??
|
||||||
|
t("providerPreset.categoryOther", {
|
||||||
|
defaultValue: "其他",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{renderPresetIcon(entry.preset)}
|
||||||
|
{entry.preset.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{getCategoryHint()}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/providers/forms/hooks/index.ts
Normal file
13
src/components/providers/forms/hooks/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export { useProviderCategory } from "./useProviderCategory";
|
||||||
|
export { useApiKeyState } from "./useApiKeyState";
|
||||||
|
export { useBaseUrlState } from "./useBaseUrlState";
|
||||||
|
export { useModelState } from "./useModelState";
|
||||||
|
export { useCodexConfigState } from "./useCodexConfigState";
|
||||||
|
export { useApiKeyLink } from "./useApiKeyLink";
|
||||||
|
export { useCustomEndpoints } from "./useCustomEndpoints";
|
||||||
|
export { useKimiModelSelector } from "./useKimiModelSelector";
|
||||||
|
export { useTemplateValues } from "./useTemplateValues";
|
||||||
|
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
||||||
|
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||||
|
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
||||||
|
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||||
64
src/components/providers/forms/hooks/useApiKeyLink.ts
Normal file
64
src/components/providers/forms/hooks/useApiKeyLink.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { AppId } from "@/lib/api";
|
||||||
|
import type { ProviderCategory } from "@/types";
|
||||||
|
import type { ProviderPreset } from "@/config/providerPresets";
|
||||||
|
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||||
|
|
||||||
|
type PresetEntry = {
|
||||||
|
id: string;
|
||||||
|
preset: ProviderPreset | CodexProviderPreset;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseApiKeyLinkProps {
|
||||||
|
appId: AppId;
|
||||||
|
category?: ProviderCategory;
|
||||||
|
selectedPresetId: string | null;
|
||||||
|
presetEntries: PresetEntry[];
|
||||||
|
formWebsiteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 API Key 获取链接的显示和 URL
|
||||||
|
*/
|
||||||
|
export function useApiKeyLink({
|
||||||
|
appId,
|
||||||
|
category,
|
||||||
|
selectedPresetId,
|
||||||
|
presetEntries,
|
||||||
|
formWebsiteUrl,
|
||||||
|
}: UseApiKeyLinkProps) {
|
||||||
|
// 判断是否显示 API Key 获取链接
|
||||||
|
const shouldShowApiKeyLink = useMemo(() => {
|
||||||
|
return (
|
||||||
|
category !== "official" &&
|
||||||
|
(category === "cn_official" ||
|
||||||
|
category === "aggregator" ||
|
||||||
|
category === "third_party")
|
||||||
|
);
|
||||||
|
}, [category]);
|
||||||
|
|
||||||
|
// 获取当前供应商的网址(用于 API Key 链接)
|
||||||
|
const getWebsiteUrl = useMemo(() => {
|
||||||
|
if (selectedPresetId && selectedPresetId !== "custom") {
|
||||||
|
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
||||||
|
if (entry) {
|
||||||
|
const preset = entry.preset;
|
||||||
|
// 第三方供应商优先使用 apiKeyUrl
|
||||||
|
return preset.category === "third_party"
|
||||||
|
? preset.apiKeyUrl || preset.websiteUrl || ""
|
||||||
|
: preset.websiteUrl || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formWebsiteUrl || "";
|
||||||
|
}, [selectedPresetId, presetEntries, formWebsiteUrl]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldShowApiKeyLink:
|
||||||
|
appId === "claude"
|
||||||
|
? shouldShowApiKeyLink
|
||||||
|
: appId === "codex"
|
||||||
|
? shouldShowApiKeyLink
|
||||||
|
: false,
|
||||||
|
websiteUrl: getWebsiteUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
73
src/components/providers/forms/hooks/useApiKeyState.ts
Normal file
73
src/components/providers/forms/hooks/useApiKeyState.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { ProviderCategory } from "@/types";
|
||||||
|
import {
|
||||||
|
getApiKeyFromConfig,
|
||||||
|
setApiKeyInConfig,
|
||||||
|
hasApiKeyField,
|
||||||
|
} from "@/utils/providerConfigUtils";
|
||||||
|
|
||||||
|
interface UseApiKeyStateProps {
|
||||||
|
initialConfig?: string;
|
||||||
|
onConfigChange: (config: string) => void;
|
||||||
|
selectedPresetId: string | null;
|
||||||
|
category?: ProviderCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 API Key 输入状态
|
||||||
|
* 自动同步 API Key 和 JSON 配置
|
||||||
|
*/
|
||||||
|
export function useApiKeyState({
|
||||||
|
initialConfig,
|
||||||
|
onConfigChange,
|
||||||
|
selectedPresetId,
|
||||||
|
category,
|
||||||
|
}: UseApiKeyStateProps) {
|
||||||
|
const [apiKey, setApiKey] = useState(() => {
|
||||||
|
if (initialConfig) {
|
||||||
|
return getApiKeyFromConfig(initialConfig);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleApiKeyChange = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
setApiKey(key);
|
||||||
|
|
||||||
|
const configString = setApiKeyInConfig(
|
||||||
|
initialConfig || "{}",
|
||||||
|
key.trim(),
|
||||||
|
{
|
||||||
|
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
|
||||||
|
// - 新增模式:selectedPresetId !== null
|
||||||
|
// - 非官方类别:category !== undefined && category !== "official"
|
||||||
|
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
||||||
|
// - 未传入 category:不创建字段(避免意外行为)
|
||||||
|
createIfMissing:
|
||||||
|
selectedPresetId !== null &&
|
||||||
|
category !== undefined &&
|
||||||
|
category !== "official",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onConfigChange(configString);
|
||||||
|
},
|
||||||
|
[initialConfig, selectedPresetId, category, onConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showApiKey = useCallback(
|
||||||
|
(config: string, isEditMode: boolean) => {
|
||||||
|
return (
|
||||||
|
selectedPresetId !== null || (isEditMode && hasApiKeyField(config))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedPresetId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
setApiKey,
|
||||||
|
handleApiKeyChange,
|
||||||
|
showApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
120
src/components/providers/forms/hooks/useBaseUrlState.ts
Normal file
120
src/components/providers/forms/hooks/useBaseUrlState.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
extractCodexBaseUrl,
|
||||||
|
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||||
|
} from "@/utils/providerConfigUtils";
|
||||||
|
import type { ProviderCategory } from "@/types";
|
||||||
|
|
||||||
|
interface UseBaseUrlStateProps {
|
||||||
|
appType: "claude" | "codex";
|
||||||
|
category: ProviderCategory | undefined;
|
||||||
|
settingsConfig: string;
|
||||||
|
codexConfig?: string;
|
||||||
|
onSettingsConfigChange: (config: string) => void;
|
||||||
|
onCodexConfigChange?: (config: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 Base URL 状态
|
||||||
|
* 支持 Claude (JSON) 和 Codex (TOML) 两种格式
|
||||||
|
*/
|
||||||
|
export function useBaseUrlState({
|
||||||
|
appType,
|
||||||
|
category,
|
||||||
|
settingsConfig,
|
||||||
|
codexConfig,
|
||||||
|
onSettingsConfigChange,
|
||||||
|
onCodexConfigChange,
|
||||||
|
}: UseBaseUrlStateProps) {
|
||||||
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
|
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||||
|
const isUpdatingRef = useRef(false);
|
||||||
|
|
||||||
|
// 从配置同步到 state(Claude)
|
||||||
|
useEffect(() => {
|
||||||
|
if (appType !== "claude") return;
|
||||||
|
if (category !== "third_party" && category !== "custom") return;
|
||||||
|
if (isUpdatingRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(settingsConfig || "{}");
|
||||||
|
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
|
||||||
|
if (typeof envUrl === "string" && envUrl && envUrl !== baseUrl) {
|
||||||
|
setBaseUrl(envUrl.trim());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [appType, category, settingsConfig, baseUrl]);
|
||||||
|
|
||||||
|
// 从配置同步到 state(Codex)
|
||||||
|
useEffect(() => {
|
||||||
|
if (appType !== "codex") return;
|
||||||
|
if (category !== "third_party" && category !== "custom") return;
|
||||||
|
if (isUpdatingRef.current) return;
|
||||||
|
if (!codexConfig) return;
|
||||||
|
|
||||||
|
const extracted = extractCodexBaseUrl(codexConfig) || "";
|
||||||
|
if (extracted !== codexBaseUrl) {
|
||||||
|
setCodexBaseUrl(extracted);
|
||||||
|
}
|
||||||
|
}, [appType, category, codexConfig, codexBaseUrl]);
|
||||||
|
|
||||||
|
// 处理 Claude Base URL 变化
|
||||||
|
const handleClaudeBaseUrlChange = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
|
setBaseUrl(sanitized);
|
||||||
|
isUpdatingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(settingsConfig || "{}");
|
||||||
|
if (!config.env) {
|
||||||
|
config.env = {};
|
||||||
|
}
|
||||||
|
config.env.ANTHROPIC_BASE_URL = sanitized;
|
||||||
|
onSettingsConfigChange(JSON.stringify(config, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settingsConfig, onSettingsConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理 Codex Base URL 变化
|
||||||
|
const handleCodexBaseUrlChange = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
|
setCodexBaseUrl(sanitized);
|
||||||
|
|
||||||
|
if (!sanitized || !onCodexConfigChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingRef.current = true;
|
||||||
|
const updatedConfig = setCodexBaseUrlInConfig(
|
||||||
|
codexConfig || "",
|
||||||
|
sanitized,
|
||||||
|
);
|
||||||
|
onCodexConfigChange(updatedConfig);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[codexConfig, onCodexConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
setBaseUrl,
|
||||||
|
codexBaseUrl,
|
||||||
|
setCodexBaseUrl,
|
||||||
|
handleClaudeBaseUrlChange,
|
||||||
|
handleCodexBaseUrlChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
185
src/components/providers/forms/hooks/useCodexCommonConfig.ts
Normal file
185
src/components/providers/forms/hooks/useCodexCommonConfig.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
updateTomlCommonConfigSnippet,
|
||||||
|
hasTomlCommonConfigSnippet,
|
||||||
|
} from "@/utils/providerConfigUtils";
|
||||||
|
|
||||||
|
const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
||||||
|
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
||||||
|
# Add your common TOML configuration here`;
|
||||||
|
|
||||||
|
interface UseCodexCommonConfigProps {
|
||||||
|
codexConfig: string;
|
||||||
|
onConfigChange: (config: string) => void;
|
||||||
|
initialData?: {
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 Codex 通用配置片段 (TOML 格式)
|
||||||
|
*/
|
||||||
|
export function useCodexCommonConfig({
|
||||||
|
codexConfig,
|
||||||
|
onConfigChange,
|
||||||
|
initialData,
|
||||||
|
}: UseCodexCommonConfigProps) {
|
||||||
|
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||||
|
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||||
|
() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(
|
||||||
|
CODEX_COMMON_CONFIG_STORAGE_KEY,
|
||||||
|
);
|
||||||
|
if (stored && stored.trim()) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage 读取失败
|
||||||
|
}
|
||||||
|
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [commonConfigError, setCommonConfigError] = useState("");
|
||||||
|
|
||||||
|
// 用于跟踪是否正在通过通用配置更新
|
||||||
|
const isUpdatingFromCommonConfig = useRef(false);
|
||||||
|
|
||||||
|
// 初始化时检查通用配置片段(编辑模式)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData?.settingsConfig) {
|
||||||
|
const config =
|
||||||
|
typeof initialData.settingsConfig.config === "string"
|
||||||
|
? initialData.settingsConfig.config
|
||||||
|
: "";
|
||||||
|
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
||||||
|
setUseCommonConfig(hasCommon);
|
||||||
|
}
|
||||||
|
}, [initialData, commonConfigSnippet]);
|
||||||
|
|
||||||
|
// 同步本地存储的通用配置片段
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
if (commonConfigSnippet.trim()) {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
CODEX_COMMON_CONFIG_STORAGE_KEY,
|
||||||
|
commonConfigSnippet,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(CODEX_COMMON_CONFIG_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [commonConfigSnippet]);
|
||||||
|
|
||||||
|
// 处理通用配置开关
|
||||||
|
const handleCommonConfigToggle = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
const { updatedConfig, error: snippetError } =
|
||||||
|
updateTomlCommonConfigSnippet(
|
||||||
|
codexConfig,
|
||||||
|
commonConfigSnippet,
|
||||||
|
checked,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (snippetError) {
|
||||||
|
setCommonConfigError(snippetError);
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommonConfigError("");
|
||||||
|
setUseCommonConfig(checked);
|
||||||
|
// 标记正在通过通用配置更新
|
||||||
|
isUpdatingFromCommonConfig.current = true;
|
||||||
|
onConfigChange(updatedConfig);
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[codexConfig, commonConfigSnippet, onConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理通用配置片段变化
|
||||||
|
const handleCommonConfigSnippetChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const previousSnippet = commonConfigSnippet;
|
||||||
|
setCommonConfigSnippetState(value);
|
||||||
|
|
||||||
|
if (!value.trim()) {
|
||||||
|
setCommonConfigError("");
|
||||||
|
if (useCommonConfig) {
|
||||||
|
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
||||||
|
codexConfig,
|
||||||
|
previousSnippet,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
onConfigChange(updatedConfig);
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
|
||||||
|
setCommonConfigError("");
|
||||||
|
|
||||||
|
// 若当前启用通用配置,需要替换为最新片段
|
||||||
|
if (useCommonConfig) {
|
||||||
|
const removeResult = updateTomlCommonConfigSnippet(
|
||||||
|
codexConfig,
|
||||||
|
previousSnippet,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (removeResult.error) {
|
||||||
|
setCommonConfigError(removeResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const addResult = updateTomlCommonConfigSnippet(
|
||||||
|
removeResult.updatedConfig,
|
||||||
|
value,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addResult.error) {
|
||||||
|
setCommonConfigError(addResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记正在通过通用配置更新,避免触发状态检查
|
||||||
|
isUpdatingFromCommonConfig.current = true;
|
||||||
|
onConfigChange(addResult.updatedConfig);
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUpdatingFromCommonConfig.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasCommon = hasTomlCommonConfigSnippet(
|
||||||
|
codexConfig,
|
||||||
|
commonConfigSnippet,
|
||||||
|
);
|
||||||
|
setUseCommonConfig(hasCommon);
|
||||||
|
}, [codexConfig, commonConfigSnippet]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
useCommonConfig,
|
||||||
|
commonConfigSnippet,
|
||||||
|
commonConfigError,
|
||||||
|
handleCommonConfigToggle,
|
||||||
|
handleCommonConfigSnippetChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
206
src/components/providers/forms/hooks/useCodexConfigState.ts
Normal file
206
src/components/providers/forms/hooks/useCodexConfigState.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
extractCodexBaseUrl,
|
||||||
|
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||||
|
} from "@/utils/providerConfigUtils";
|
||||||
|
|
||||||
|
interface UseCodexConfigStateProps {
|
||||||
|
initialData?: {
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 Codex 配置状态
|
||||||
|
* Codex 配置包含两部分:auth.json (JSON) 和 config.toml (TOML 字符串)
|
||||||
|
*/
|
||||||
|
export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||||
|
const [codexAuth, setCodexAuthState] = useState("");
|
||||||
|
const [codexConfig, setCodexConfigState] = useState("");
|
||||||
|
const [codexApiKey, setCodexApiKey] = useState("");
|
||||||
|
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||||
|
const [codexAuthError, setCodexAuthError] = useState("");
|
||||||
|
|
||||||
|
const isUpdatingCodexBaseUrlRef = useRef(false);
|
||||||
|
|
||||||
|
// 初始化 Codex 配置(编辑模式)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialData) return;
|
||||||
|
|
||||||
|
const config = initialData.settingsConfig;
|
||||||
|
if (typeof config === "object" && config !== null) {
|
||||||
|
// 设置 auth.json
|
||||||
|
const auth = (config as any).auth || {};
|
||||||
|
setCodexAuthState(JSON.stringify(auth, null, 2));
|
||||||
|
|
||||||
|
// 设置 config.toml
|
||||||
|
const configStr =
|
||||||
|
typeof (config as any).config === "string"
|
||||||
|
? (config as any).config
|
||||||
|
: "";
|
||||||
|
setCodexConfigState(configStr);
|
||||||
|
|
||||||
|
// 提取 Base URL
|
||||||
|
const initialBaseUrl = extractCodexBaseUrl(configStr);
|
||||||
|
if (initialBaseUrl) {
|
||||||
|
setCodexBaseUrl(initialBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 API Key
|
||||||
|
try {
|
||||||
|
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||||
|
setCodexApiKey(auth.OPENAI_API_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// 与 TOML 配置保持基础 URL 同步
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUpdatingCodexBaseUrlRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const extracted = extractCodexBaseUrl(codexConfig) || "";
|
||||||
|
if (extracted !== codexBaseUrl) {
|
||||||
|
setCodexBaseUrl(extracted);
|
||||||
|
}
|
||||||
|
}, [codexConfig, codexBaseUrl]);
|
||||||
|
|
||||||
|
// 验证 Codex Auth JSON
|
||||||
|
const validateCodexAuth = useCallback((value: string): string => {
|
||||||
|
if (!value.trim()) return "";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return "Auth JSON must be an object";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
} catch {
|
||||||
|
return "Invalid JSON format";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 设置 auth 并验证
|
||||||
|
const setCodexAuth = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setCodexAuthState(value);
|
||||||
|
setCodexAuthError(validateCodexAuth(value));
|
||||||
|
},
|
||||||
|
[validateCodexAuth],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置 config (支持函数更新)
|
||||||
|
const setCodexConfig = useCallback(
|
||||||
|
(value: string | ((prev: string) => string)) => {
|
||||||
|
setCodexConfigState((prev) =>
|
||||||
|
typeof value === "function"
|
||||||
|
? (value as (input: string) => string)(prev)
|
||||||
|
: value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理 Codex API Key 输入并写回 auth.json
|
||||||
|
const handleCodexApiKeyChange = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
setCodexApiKey(key);
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(codexAuth || "{}");
|
||||||
|
auth.OPENAI_API_KEY = key.trim();
|
||||||
|
setCodexAuth(JSON.stringify(auth, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[codexAuth, setCodexAuth],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理 Codex Base URL 变化
|
||||||
|
const handleCodexBaseUrlChange = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
|
setCodexBaseUrl(sanitized);
|
||||||
|
|
||||||
|
if (!sanitized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingCodexBaseUrlRef.current = true;
|
||||||
|
setCodexConfig((prev) => setCodexBaseUrlInConfig(prev, sanitized));
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingCodexBaseUrlRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[setCodexConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理 config 变化(同步 Base URL)
|
||||||
|
const handleCodexConfigChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setCodexConfig(value);
|
||||||
|
|
||||||
|
if (!isUpdatingCodexBaseUrlRef.current) {
|
||||||
|
const extracted = extractCodexBaseUrl(value) || "";
|
||||||
|
if (extracted !== codexBaseUrl) {
|
||||||
|
setCodexBaseUrl(extracted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setCodexConfig, codexBaseUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 重置配置(用于预设切换)
|
||||||
|
const resetCodexConfig = useCallback(
|
||||||
|
(auth: Record<string, unknown>, config: string) => {
|
||||||
|
const authString = JSON.stringify(auth, null, 2);
|
||||||
|
setCodexAuth(authString);
|
||||||
|
setCodexConfig(config);
|
||||||
|
|
||||||
|
const baseUrl = extractCodexBaseUrl(config);
|
||||||
|
if (baseUrl) {
|
||||||
|
setCodexBaseUrl(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 API Key
|
||||||
|
try {
|
||||||
|
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||||
|
setCodexApiKey(auth.OPENAI_API_KEY);
|
||||||
|
} else {
|
||||||
|
setCodexApiKey("");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCodexApiKey("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setCodexAuth, setCodexConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取 API Key(从 auth JSON)
|
||||||
|
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(authString || "{}");
|
||||||
|
return typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
codexAuth,
|
||||||
|
codexConfig,
|
||||||
|
codexApiKey,
|
||||||
|
codexBaseUrl,
|
||||||
|
codexAuthError,
|
||||||
|
setCodexAuth,
|
||||||
|
setCodexConfig,
|
||||||
|
handleCodexApiKeyChange,
|
||||||
|
handleCodexBaseUrlChange,
|
||||||
|
handleCodexConfigChange,
|
||||||
|
resetCodexConfig,
|
||||||
|
getCodexAuthApiKey,
|
||||||
|
validateCodexAuth,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import TOML from "smol-toml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex config.toml 格式校验 Hook
|
||||||
|
* 使用 smol-toml 进行实时 TOML 语法校验(带 debounce)
|
||||||
|
*/
|
||||||
|
export function useCodexTomlValidation() {
|
||||||
|
const [configError, setConfigError] = useState("");
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 TOML 格式
|
||||||
|
* @param tomlText - 待校验的 TOML 文本
|
||||||
|
* @returns 是否校验通过
|
||||||
|
*/
|
||||||
|
const validateToml = useCallback((tomlText: string): boolean => {
|
||||||
|
// 空字符串视为合法(允许为空)
|
||||||
|
if (!tomlText.trim()) {
|
||||||
|
setConfigError("");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
TOML.parse(tomlText);
|
||||||
|
setConfigError("");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "TOML 格式错误";
|
||||||
|
setConfigError(errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带 debounce 的校验函数(500ms 延迟)
|
||||||
|
* @param tomlText - 待校验的 TOML 文本
|
||||||
|
*/
|
||||||
|
const debouncedValidate = useCallback(
|
||||||
|
(tomlText: string) => {
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新的定时器
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
validateToml(tomlText);
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
[validateToml],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空错误信息
|
||||||
|
*/
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setConfigError("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configError,
|
||||||
|
validateToml,
|
||||||
|
debouncedValidate,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
}
|
||||||
189
src/components/providers/forms/hooks/useCommonConfigSnippet.ts
Normal file
189
src/components/providers/forms/hooks/useCommonConfigSnippet.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
updateCommonConfigSnippet,
|
||||||
|
hasCommonConfigSnippet,
|
||||||
|
validateJsonConfig,
|
||||||
|
} from "@/utils/providerConfigUtils";
|
||||||
|
|
||||||
|
const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet";
|
||||||
|
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
|
||||||
|
"includeCoAuthoredBy": false
|
||||||
|
}`;
|
||||||
|
|
||||||
|
interface UseCommonConfigSnippetProps {
|
||||||
|
settingsConfig: string;
|
||||||
|
onConfigChange: (config: string) => void;
|
||||||
|
initialData?: {
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 Claude 通用配置片段
|
||||||
|
*/
|
||||||
|
export function useCommonConfigSnippet({
|
||||||
|
settingsConfig,
|
||||||
|
onConfigChange,
|
||||||
|
initialData,
|
||||||
|
}: UseCommonConfigSnippetProps) {
|
||||||
|
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||||
|
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||||
|
() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return DEFAULT_COMMON_CONFIG_SNIPPET;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(COMMON_CONFIG_STORAGE_KEY);
|
||||||
|
if (stored && stored.trim()) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage 读取失败
|
||||||
|
}
|
||||||
|
return DEFAULT_COMMON_CONFIG_SNIPPET;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [commonConfigError, setCommonConfigError] = useState("");
|
||||||
|
|
||||||
|
// 用于跟踪是否正在通过通用配置更新
|
||||||
|
const isUpdatingFromCommonConfig = useRef(false);
|
||||||
|
|
||||||
|
// 初始化时检查通用配置片段(编辑模式)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||||
|
const hasCommon = hasCommonConfigSnippet(
|
||||||
|
configString,
|
||||||
|
commonConfigSnippet,
|
||||||
|
);
|
||||||
|
setUseCommonConfig(hasCommon);
|
||||||
|
}
|
||||||
|
}, [initialData, commonConfigSnippet]);
|
||||||
|
|
||||||
|
// 同步本地存储的通用配置片段
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
if (commonConfigSnippet.trim()) {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
COMMON_CONFIG_STORAGE_KEY,
|
||||||
|
commonConfigSnippet,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [commonConfigSnippet]);
|
||||||
|
|
||||||
|
// 处理通用配置开关
|
||||||
|
const handleCommonConfigToggle = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
|
||||||
|
settingsConfig,
|
||||||
|
commonConfigSnippet,
|
||||||
|
checked,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (snippetError) {
|
||||||
|
setCommonConfigError(snippetError);
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommonConfigError("");
|
||||||
|
setUseCommonConfig(checked);
|
||||||
|
// 标记正在通过通用配置更新
|
||||||
|
isUpdatingFromCommonConfig.current = true;
|
||||||
|
onConfigChange(updatedConfig);
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[settingsConfig, commonConfigSnippet, onConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理通用配置片段变化
|
||||||
|
const handleCommonConfigSnippetChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const previousSnippet = commonConfigSnippet;
|
||||||
|
setCommonConfigSnippetState(value);
|
||||||
|
|
||||||
|
if (!value.trim()) {
|
||||||
|
setCommonConfigError("");
|
||||||
|
if (useCommonConfig) {
|
||||||
|
const { updatedConfig } = updateCommonConfigSnippet(
|
||||||
|
settingsConfig,
|
||||||
|
previousSnippet,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
onConfigChange(updatedConfig);
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证JSON格式
|
||||||
|
const validationError = validateJsonConfig(value, "通用配置片段");
|
||||||
|
if (validationError) {
|
||||||
|
setCommonConfigError(validationError);
|
||||||
|
} else {
|
||||||
|
setCommonConfigError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若当前启用通用配置且格式正确,需要替换为最新片段
|
||||||
|
if (useCommonConfig && !validationError) {
|
||||||
|
const removeResult = updateCommonConfigSnippet(
|
||||||
|
settingsConfig,
|
||||||
|
previousSnippet,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (removeResult.error) {
|
||||||
|
setCommonConfigError(removeResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const addResult = updateCommonConfigSnippet(
|
||||||
|
removeResult.updatedConfig,
|
||||||
|
value,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addResult.error) {
|
||||||
|
setCommonConfigError(addResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记正在通过通用配置更新,避免触发状态检查
|
||||||
|
isUpdatingFromCommonConfig.current = true;
|
||||||
|
onConfigChange(addResult.updatedConfig);
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUpdatingFromCommonConfig.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasCommon = hasCommonConfigSnippet(
|
||||||
|
settingsConfig,
|
||||||
|
commonConfigSnippet,
|
||||||
|
);
|
||||||
|
setUseCommonConfig(hasCommon);
|
||||||
|
}, [settingsConfig, commonConfigSnippet]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
useCommonConfig,
|
||||||
|
commonConfigSnippet,
|
||||||
|
commonConfigError,
|
||||||
|
handleCommonConfigToggle,
|
||||||
|
handleCommonConfigSnippetChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
92
src/components/providers/forms/hooks/useCustomEndpoints.ts
Normal file
92
src/components/providers/forms/hooks/useCustomEndpoints.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { AppId } from "@/lib/api";
|
||||||
|
import type { CustomEndpoint } from "@/types";
|
||||||
|
import type { ProviderPreset } from "@/config/providerPresets";
|
||||||
|
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||||
|
|
||||||
|
type PresetEntry = {
|
||||||
|
id: string;
|
||||||
|
preset: ProviderPreset | CodexProviderPreset;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseCustomEndpointsProps {
|
||||||
|
appId: AppId;
|
||||||
|
selectedPresetId: string | null;
|
||||||
|
presetEntries: PresetEntry[];
|
||||||
|
draftCustomEndpoints: string[];
|
||||||
|
baseUrl: string;
|
||||||
|
codexBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集和管理自定义端点
|
||||||
|
*
|
||||||
|
* 收集来源:
|
||||||
|
* 1. 用户在测速弹窗中新增的自定义端点
|
||||||
|
* 2. 预设中的 endpointCandidates
|
||||||
|
* 3. 当前选中的 Base URL
|
||||||
|
*/
|
||||||
|
export function useCustomEndpoints({
|
||||||
|
appId,
|
||||||
|
selectedPresetId,
|
||||||
|
presetEntries,
|
||||||
|
draftCustomEndpoints,
|
||||||
|
baseUrl,
|
||||||
|
codexBaseUrl,
|
||||||
|
}: UseCustomEndpointsProps) {
|
||||||
|
const customEndpointsMap = useMemo(() => {
|
||||||
|
const urlSet = new Set<string>();
|
||||||
|
|
||||||
|
// 辅助函数:标准化并添加 URL
|
||||||
|
const push = (raw?: string) => {
|
||||||
|
const url = (raw || "").trim().replace(/\/+$/, "");
|
||||||
|
if (url) urlSet.add(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 自定义端点(来自用户新增)
|
||||||
|
for (const u of draftCustomEndpoints) push(u);
|
||||||
|
|
||||||
|
// 2. 预设端点候选
|
||||||
|
if (selectedPresetId && selectedPresetId !== "custom") {
|
||||||
|
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
||||||
|
if (entry) {
|
||||||
|
const preset = entry.preset as any;
|
||||||
|
if (Array.isArray(preset?.endpointCandidates)) {
|
||||||
|
for (const u of preset.endpointCandidates as string[]) push(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 当前 Base URL
|
||||||
|
if (appId === "codex") {
|
||||||
|
push(codexBaseUrl);
|
||||||
|
} else {
|
||||||
|
push(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 CustomEndpoint map
|
||||||
|
const urls = Array.from(urlSet.values());
|
||||||
|
if (urls.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const customMap: Record<string, CustomEndpoint> = {};
|
||||||
|
for (const url of urls) {
|
||||||
|
if (!customMap[url]) {
|
||||||
|
customMap[url] = { url, addedAt: now, lastUsed: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return customMap;
|
||||||
|
}, [
|
||||||
|
appId,
|
||||||
|
selectedPresetId,
|
||||||
|
presetEntries,
|
||||||
|
draftCustomEndpoints,
|
||||||
|
baseUrl,
|
||||||
|
codexBaseUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return customEndpointsMap;
|
||||||
|
}
|
||||||
116
src/components/providers/forms/hooks/useKimiModelSelector.ts
Normal file
116
src/components/providers/forms/hooks/useKimiModelSelector.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
interface UseKimiModelSelectorProps {
|
||||||
|
initialData?: {
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
settingsConfig: string;
|
||||||
|
onConfigChange: (config: string) => void;
|
||||||
|
selectedPresetId: string | null;
|
||||||
|
presetName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 Kimi 模型选择器的状态和逻辑
|
||||||
|
*/
|
||||||
|
export function useKimiModelSelector({
|
||||||
|
initialData,
|
||||||
|
settingsConfig,
|
||||||
|
onConfigChange,
|
||||||
|
selectedPresetId,
|
||||||
|
presetName = "",
|
||||||
|
}: UseKimiModelSelectorProps) {
|
||||||
|
const [kimiAnthropicModel, setKimiAnthropicModel] = useState("");
|
||||||
|
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
|
||||||
|
useState("");
|
||||||
|
|
||||||
|
// 判断是否显示 Kimi 模型选择器
|
||||||
|
const shouldShowKimiSelector =
|
||||||
|
selectedPresetId !== null &&
|
||||||
|
selectedPresetId !== "custom" &&
|
||||||
|
presetName.includes("Kimi");
|
||||||
|
|
||||||
|
// 判断是否正在编辑 Kimi 供应商
|
||||||
|
const isEditingKimi = Boolean(
|
||||||
|
initialData &&
|
||||||
|
settingsConfig.includes("api.moonshot.cn") &&
|
||||||
|
settingsConfig.includes("ANTHROPIC_MODEL"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldShow = shouldShowKimiSelector || isEditingKimi;
|
||||||
|
|
||||||
|
// 初始化 Kimi 模型选择(编辑模式)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
initialData?.settingsConfig &&
|
||||||
|
typeof initialData.settingsConfig === "object"
|
||||||
|
) {
|
||||||
|
const config = initialData.settingsConfig as {
|
||||||
|
env?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (config.env) {
|
||||||
|
const model =
|
||||||
|
typeof config.env.ANTHROPIC_MODEL === "string"
|
||||||
|
? config.env.ANTHROPIC_MODEL
|
||||||
|
: "";
|
||||||
|
const smallFastModel =
|
||||||
|
typeof config.env.ANTHROPIC_SMALL_FAST_MODEL === "string"
|
||||||
|
? config.env.ANTHROPIC_SMALL_FAST_MODEL
|
||||||
|
: "";
|
||||||
|
setKimiAnthropicModel(model);
|
||||||
|
setKimiAnthropicSmallFastModel(smallFastModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// 处理 Kimi 模型变化
|
||||||
|
const handleKimiModelChange = useCallback(
|
||||||
|
(
|
||||||
|
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
if (field === "ANTHROPIC_MODEL") {
|
||||||
|
setKimiAnthropicModel(value);
|
||||||
|
} else {
|
||||||
|
setKimiAnthropicSmallFastModel(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新配置 JSON
|
||||||
|
try {
|
||||||
|
const currentConfig = JSON.parse(settingsConfig || "{}");
|
||||||
|
if (!currentConfig.env) currentConfig.env = {};
|
||||||
|
currentConfig.env[field] = value;
|
||||||
|
|
||||||
|
const updatedConfigString = JSON.stringify(currentConfig, null, 2);
|
||||||
|
onConfigChange(updatedConfigString);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("更新 Kimi 模型配置失败:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settingsConfig, onConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当选择 Kimi 预设时,同步模型值
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldShowKimiSelector && settingsConfig) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(settingsConfig);
|
||||||
|
if (config.env) {
|
||||||
|
const model = config.env.ANTHROPIC_MODEL || "";
|
||||||
|
const smallFastModel = config.env.ANTHROPIC_SMALL_FAST_MODEL || "";
|
||||||
|
setKimiAnthropicModel(model);
|
||||||
|
setKimiAnthropicSmallFastModel(smallFastModel);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [shouldShowKimiSelector, settingsConfig]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldShow,
|
||||||
|
kimiAnthropicModel,
|
||||||
|
kimiAnthropicSmallFastModel,
|
||||||
|
handleKimiModelChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
58
src/components/providers/forms/hooks/useModelState.ts
Normal file
58
src/components/providers/forms/hooks/useModelState.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface UseModelStateProps {
|
||||||
|
settingsConfig: string;
|
||||||
|
onConfigChange: (config: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理模型选择状态
|
||||||
|
* 支持 ANTHROPIC_MODEL 和 ANTHROPIC_SMALL_FAST_MODEL
|
||||||
|
*/
|
||||||
|
export function useModelState({
|
||||||
|
settingsConfig,
|
||||||
|
onConfigChange,
|
||||||
|
}: UseModelStateProps) {
|
||||||
|
const [claudeModel, setClaudeModel] = useState("");
|
||||||
|
const [claudeSmallFastModel, setClaudeSmallFastModel] = useState("");
|
||||||
|
|
||||||
|
const handleModelChange = useCallback(
|
||||||
|
(
|
||||||
|
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
if (field === "ANTHROPIC_MODEL") {
|
||||||
|
setClaudeModel(value);
|
||||||
|
} else {
|
||||||
|
setClaudeSmallFastModel(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentConfig = settingsConfig
|
||||||
|
? JSON.parse(settingsConfig)
|
||||||
|
: { env: {} };
|
||||||
|
if (!currentConfig.env) currentConfig.env = {};
|
||||||
|
|
||||||
|
if (value.trim()) {
|
||||||
|
currentConfig.env[field] = value.trim();
|
||||||
|
} else {
|
||||||
|
delete currentConfig.env[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfigChange(JSON.stringify(currentConfig, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
// 如果 JSON 解析失败,不做处理
|
||||||
|
console.error("Failed to update model config:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settingsConfig, onConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
claudeModel,
|
||||||
|
setClaudeModel,
|
||||||
|
claudeSmallFastModel,
|
||||||
|
setClaudeSmallFastModel,
|
||||||
|
handleModelChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user