diff --git a/README.md b/README.md index 737480ac1..fd3c0205b 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,18 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG ## ❤️Sponsor -[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) +[![MiniMax](assets/partners/banners/minimax-en.jpg)](https://bit.ly/3Nue8mA) -This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)! +MiniMax M2.1 is an open-source, SOTA model built for real-world development and agentic workflows. It delivers top-tier performance on major coding benchmarks such as SWE, VIBE, and Multi-SWE. Powered by a 10B active / 230B total MoE architecture, M2.1 enables faster inference, easier deployment, and even local execution. It excels at coding, navigating digital environments, and handling long, multi-step tasks at scale. + +[Click](https://bit.ly/3Nue8mA) to get an exclusive 12% off the MiniMax Coding Plan! --- - + @@ -48,6 +50,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric + + + + + diff --git a/README_JA.md b/README_JA.md index 303712258..65396cebf 100644 --- a/README_JA.md +++ b/README_JA.md @@ -15,9 +15,11 @@ ## ❤️スポンサー -[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) +[![MiniMax](assets/partners/banners/minimax-en.jpg)](https://bit.ly/3Nue8mA) -本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります! +MiniMax M2.1 は、実務開発とエージェントワークフロー向けに構築されたオープンソースの最先端モデルです。100 億のアクティブパラメータ / 2,300 億の総パラメータを持つ MoE アーキテクチャにより、高速な推論、簡単なデプロイ、ローカル実行にも対応します。SWE、VIBE、Multi-SWE などの主要コーディングベンチマークでトップクラスの性能を発揮し、コーディング、デジタル環境のナビゲーション、大規模な多段階タスクの処理に優れています。 + +[こちら](https://bit.ly/3Nue8mA)から MiniMax Coding Plan の限定 12% オフを入手! --- @@ -48,6 +50,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / + + + + + diff --git a/README_ZH.md b/README_ZH.md index e33a32f39..587365fe1 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -15,16 +15,18 @@ ## ❤️赞助商 -[![智谱 GLM](assets/partners/banners/glm-zh.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII) +[![MiniMax](assets/partners/banners/minimax-zh.jpeg)](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link) -感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。 +MiniMax M2.x 系列模型是面向实际开发与智能体工作流打造的编码模型,M2.1 基于 100 亿激活 / 2300 亿总参的混合专家架构打造,推理更快、部署更便捷且支持本地运行,在 SWE、VIBE、Multi-SWE 等主流代码评测基准中均表现顶尖,擅长代码开发、数字环境适配及规模化处理长链路多步骤任务。 + +[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠! ---
PackyCodeThanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using this link and enter the "cc-switch" promo code during recharge to get 10% off.Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using this link and enter the "cc-switch" promo code during first recharge to get 10% off.
Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! Register here
RightCodeThank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and supports quota rollovers—unused quota from one day can be carried over and used the next day. Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via this link, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.
AICoding Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via this link get 10% off their first top-up!DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!登録はこちら
RightCode本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して提供しています。特に高いコストパフォーマンスを誇る Codex の月額プランを主力としており、未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)点が特長です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、こちらのリンクからご登録いただくと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。
AICoding AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。こちらのリンクから登録した CC Switch ユーザーは、初回チャージ 10% オフ!
- + @@ -49,6 +51,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!点击这里注册 + + + + + diff --git a/assets/partners/banners/minimax-en.jpg b/assets/partners/banners/minimax-en.jpg new file mode 100644 index 000000000..381974431 Binary files /dev/null and b/assets/partners/banners/minimax-en.jpg differ diff --git a/assets/partners/banners/minimax-zh.jpeg b/assets/partners/banners/minimax-zh.jpeg new file mode 100644 index 000000000..0bee98460 Binary files /dev/null and b/assets/partners/banners/minimax-zh.jpeg differ diff --git a/assets/partners/logos/rightcode.jpg b/assets/partners/logos/rightcode.jpg new file mode 100644 index 000000000..06ac3a63e Binary files /dev/null and b/assets/partners/logos/rightcode.jpg differ diff --git a/docs/user-manual/1-getting-started/1.1-introduction.md b/docs/user-manual/1-getting-started/1.1-introduction.md new file mode 100644 index 000000000..536662c30 --- /dev/null +++ b/docs/user-manual/1-getting-started/1.1-introduction.md @@ -0,0 +1,64 @@ +# 1.1 软件介绍 + +## 什么是 CC Switch + +CC Switch 是一款跨平台桌面应用,专为使用 AI 编程工具的开发者设计。它帮助你统一管理 **Claude Code**、**Codex**、**Gemini CLI**、**OpenCode** 四大 AI 编程工具的配置。 + +## 解决什么问题 + +在日常开发中,你可能会遇到这些痛点: + +- **多供应商切换麻烦**:使用不同的 API 供应商(官方、中转服务商),需要手动修改配置文件 +- **配置分散难管理**:Claude、Codex、Gemini、OpenCode 各有独立的配置文件,格式不同 +- **无法监控用量**:不知道 API 调用了多少次,花了多少钱 +- **服务不稳定**:单一供应商出问题时,整个工作流中断 + +CC Switch 通过统一的界面解决这些问题。 + +## 核心功能 + +### 供应商管理 +- 一键切换多个 API 供应商配置 +- 支持预设模板,快速添加常用供应商 +- 统一供应商功能,跨应用共享配置 +- 用量查询与余额显示 +- 端点速度测试 + +### 扩展功能 +- **MCP 服务器**:管理 Model Context Protocol 服务器,扩展 AI 能力 +- **Prompts**:管理系统提示词预设,快速切换不同场景 +- **Skills**:安装和管理技能扩展 + +### 代理与高可用 +- 本地代理服务,记录请求日志和用量统计 +- 自动故障转移,主供应商失败时自动切换备用 +- 熔断器机制,防止频繁重试失败的供应商 +- 详细的 Token 用量追踪与成本估算 + +## 支持的应用 + +| 应用 | 说明 | +|------|------| +| **Claude Code** | Anthropic 官方的 AI 编程助手 | +| **Codex** | OpenAI 的代码生成工具 | +| **Gemini CLI** | Google 的 AI 命令行工具 | +| **OpenCode** | 开源 AI 编程终端工具 | + +## 支持的平台 + +- **Windows** 10 及以上 +- **macOS** 10.15 (Catalina) 及以上 +- **Linux** Ubuntu 22.04+ / Debian 11+ / Fedora 34+ + +## 技术架构 + +CC Switch 使用现代化的技术栈构建: + +- **前端**:React 18 + TypeScript + Tailwind CSS +- **后端**:Tauri 2 + Rust +- **数据存储**:SQLite(供应商、MCP、Prompts)+ JSON(设备设置) + +这种架构确保了: +- 跨平台一致的体验 +- 原生级别的性能 +- 安全的本地数据存储 diff --git a/docs/user-manual/1-getting-started/1.2-installation.md b/docs/user-manual/1-getting-started/1.2-installation.md new file mode 100644 index 000000000..fdda3c5ec --- /dev/null +++ b/docs/user-manual/1-getting-started/1.2-installation.md @@ -0,0 +1,243 @@ +# 1.2 安装指南 + +## 前置要求 + +### 安装 Node.js + +CC Switch 管理的 CLI 工具(Claude Code、Codex、Gemini CLI)需要 Node.js 环境。 + +**推荐版本**:Node.js 18 LTS 或更高版本 + +#### Windows + +1. 访问 [Node.js 官网](https://nodejs.org/) + +2. 下载 LTS 版本安装包 + +3. 运行安装程序,按提示完成安装 + +4. 验证安装: + + ```bash + node --version + npm --version + ``` + +#### macOS + +```bash +# 使用 Homebrew 安装 +brew install node + +# 或使用 nvm(推荐) +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash +nvm install --lts +``` + +#### Linux + +```bash +# Ubuntu/Debian +curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - +sudo apt-get install -y nodejs + +# 或使用 nvm +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash +nvm install --lts +``` + +### 安装 CLI 工具 + +#### Claude Code + +**方式一:Homebrew(macOS 推荐)** + +```bash +brew install claude-code +``` + +**方式二:npm** + +```bash +npm install -g @anthropic-ai/claude-code + +# 国内用户如下载慢,使用镜像源 +npm install -g @anthropic-ai/claude-code --registry=https://registry.npmmirror.com +``` + +#### Codex + +**方式一:Homebrew(macOS 推荐)** + +```bash +brew install codex +``` + +**方式二:npm** + +```bash +npm install -g @openai/codex + +# 国内用户如下载慢,使用镜像源 +npm install -g @openai/codex --registry=https://registry.npmmirror.com +``` + +#### Gemini CLI + +**方式一:Homebrew(macOS 推荐)** + +```bash +brew install gemini-cli +``` + +**方式二:npm** + +```bash +npm install -g @google/gemini-cli + +# 国内用户如下载慢,使用镜像源 +npm install -g @google/gemini-cli --registry=https://registry.npmmirror.com +``` + +> 💡 **提示**:如果经常遇到下载慢的问题,可以全局设置镜像源: +> ```bash +> npm config set registry https://registry.npmmirror.com +> ``` + +--- + +## Windows + +### 安装包方式 + +1. 访问 [Releases 页面](https://github.com/farion1231/cc-switch/releases) +2. 下载 `CC-Switch-v{版本号}-Windows.msi` +3. 双击运行安装程序 +4. 按提示完成安装 + +### 绿色版(免安装) + +1. 下载 `CC-Switch-v{版本号}-Windows-Portable.zip` +2. 解压到任意目录 +3. 运行 `CC-Switch.exe` + +## macOS + +### 方式一:Homebrew(推荐) + +```bash +# 添加 tap +brew tap farion1231/ccswitch + +# 安装 +brew install --cask cc-switch +``` + +更新到最新版本: + +```bash +brew upgrade --cask cc-switch +``` + +### 方式二:手动下载 + +1. 下载 `CC-Switch-v{版本号}-macOS.zip` +2. 解压得到 `CC Switch.app` +3. 拖动到「应用程序」文件夹 + +### 首次打开提示 + +由于开发者没有 Apple 开发者账号,首次打开可能出现「未知开发者」警告: + +**推荐解决方法**: +打开终端执行以下命令: +```bash +sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/ +``` + +**备选解决方法(通过系统设置)**: +1. 关闭警告弹窗 +2. 打开「系统设置」→「隐私与安全性」 +3. 找到 CC Switch 相关提示,点击「仍要打开」 +4. 再次打开应用即可正常使用 + +## Linux + +### ArchLinux + +使用 AUR 助手安装: + +```bash +# 使用 paru +paru -S cc-switch-bin + +# 或使用 yay +yay -S cc-switch-bin +``` + +### Debian / Ubuntu + +1. 下载 `CC-Switch-v{版本号}-Linux.deb` +2. 安装: + +```bash +sudo dpkg -i CC-Switch-v{版本号}-Linux.deb + +# 如果有依赖问题 +sudo apt-get install -f +``` + +### AppImage(通用) + +1. 下载 `CC-Switch-v{版本号}-Linux.AppImage` +2. 添加执行权限: + +```bash +chmod +x CC-Switch-v{版本号}-Linux.AppImage +``` + +3. 运行: + +```bash +./CC-Switch-v{版本号}-Linux.AppImage +``` + +## 验证安装 + +安装完成后,启动 CC Switch: + +1. 应用窗口正常显示 +2. 系统托盘出现 CC Switch 图标 +3. 能够切换 Claude / Codex / Gemini 三个应用 + +## 自动更新 + +CC Switch 内置自动更新功能: + +- 启动时自动检查更新 +- 有新版本时在界面显示更新提示 +- 点击即可下载并安装 + +也可以在「设置 → 关于」中手动检查更新。 + +## 卸载 + +### Windows + +- 通过「设置 → 应用」卸载 +- 或运行安装目录下的卸载程序 + +### macOS + +- 将 `CC Switch.app` 移到废纸篓 +- 可选:删除配置目录 `~/.cc-switch/` + +### Linux + +```bash +# Debian/Ubuntu +sudo apt remove cc-switch + +# ArchLinux +paru -R cc-switch-bin +``` diff --git a/docs/user-manual/1-getting-started/1.3-interface.md b/docs/user-manual/1-getting-started/1.3-interface.md new file mode 100644 index 000000000..e88afb96e --- /dev/null +++ b/docs/user-manual/1-getting-started/1.3-interface.md @@ -0,0 +1,169 @@ +# 1.3 界面概览 + +## 主界面布局 + +![image-20260108001629138](../assets/image-20260108001629138.png) + +## 顶部导航栏 + +| 序号 | 元素 | 功能说明 | +|------|------|----------| +| ① | Logo | 点击访问 GitHub 项目页 | +| ② | 设置按钮 | 打开设置页面(快捷键 `Cmd/Ctrl + ,`) | +| ③ | 代理开关 | 启动/停止本地代理服务 | +| ④ | 应用切换器 | 切换 Claude / Codex / Gemini / OpenCode | +| ⑤ | 功能区 | Skills / Prompts / MCP 入口 | +| ⑥ | 添加按钮 | 添加新供应商 | + +### 应用切换器 + +点击下拉菜单切换当前管理的应用: + +- **Claude** - 管理 Claude Code 配置 +- **Codex** - 管理 Codex 配置 +- **Gemini** - 管理 Gemini CLI 配置 +- **OpenCode** - 管理 OpenCode 配置 + +切换后,供应商列表会显示对应应用的配置。 + +### 功能区按钮 + +| 按钮 | 功能 | 可见条件 | +|------|------|----------| +| Skills | 技能扩展管理 | 始终可见 | +| Prompts | 系统提示词管理 | 始终可见 | +| MCP | MCP 服务器管理 | 始终可见 | + +## 供应商卡片 + +每个供应商以卡片形式展示,从左到右依次包含以下元素: + +### 卡片元素(从左到右) + +| 序号 | 元素 | 图标 | 功能说明 | +|------|------|------|----------| +| ① | 拖拽手柄 | ≡ | 按住上下拖动调整供应商顺序 | +| ② | 供应商图标 | 🔷 | 显示供应商品牌图标,可自定义颜色 | +| ③ | 供应商信息 | - | 名称、备注/端点地址(可点击打开官网) | +| ④ | 用量信息 | - | 显示剩余额度,多套餐时显示套餐数量 | +| ⑤ | 启用按钮 | ▶ | 切换为当前使用的供应商 | +| ⑥ | 编辑按钮 | ✏️ | 编辑供应商配置 | +| ⑦ | 复制按钮 | 📋 | 复制供应商(创建副本) | +| ⑧ | 测速按钮 | 🧪 | 测试模型可用性和响应速度 | +| ⑨ | 用量查询 | 📊 | 配置用量查询脚本 | +| ⑩ | 删除按钮 | 🗑️ | 删除供应商(当前启用时禁用) | + +> 💡 **提示**:操作按钮区域(⑤-⑩)在鼠标悬停时显示,平时隐藏以保持界面简洁。 + +### 按钮详细说明 + +| 按钮 | 状态变化 | 说明 | +|------|----------|------| +| **启用** | 已启用时显示 ✓ 并禁用 | 故障转移模式下变为「加入/已加入」 | +| **编辑** | 始终可用 | 打开编辑面板修改配置 | +| **复制** | 始终可用 | 创建供应商副本,名称后缀 `copy` | +| **测速** | 测试中显示加载动画 | 仅代理服务运行时可用 | +| **用量查询** | 始终可用 | 配置自定义用量查询脚本 | +| **删除** | 当前启用时半透明禁用 | 需先切换到其他供应商才能删除 | + +### 卡片状态 + +| 状态 | 边框颜色 | 说明 | +|------|----------|------| +| **当前启用** | 🔵 蓝色边框 | 普通模式下当前使用的供应商 | +| **代理活跃** | 🟢 绿色边框 | 代理接管模式下实际使用的供应商 | +| **普通状态** | 默认边框 | 未启用的供应商 | +| **故障转移中** | 显示优先级徽章 | 如 P1、P2 表示故障转移优先级 | + +### 健康状态徽章 + +在代理模式下,加入故障转移队列的供应商会显示健康状态: + +| 徽章 | 颜色 | 说明 | +|------|------|------| +| 健康 | 🟢 绿色 | 连续失败 0 次 | +| 警告 | 🟡 黄色 | 连续失败 1-2 次 | +| 不健康 | 🔴 红色 | 连续失败 ≥3 次,可能触发熔断 | + + +## 系统托盘 + +CC Switch 在系统托盘显示图标,提供快速操作入口。 + +### 托盘菜单结构 + +![image-20260108002153668](../assets/image-20260108002153668.png) + +### 菜单功能 + +| 菜单项 | 功能 | +|--------|------| +| 打开主界面 | 显示主窗口并聚焦 | +| 应用分组 | 按 Claude/Codex/Gemini/OpenCode 分组显示供应商 | +| 供应商列表 | 点击切换,当前启用的显示勾选标记 | +| 退出 | 完全退出应用 | + +### 多语言支持 + +托盘菜单支持三种语言,根据设置自动切换: + +| 语言 | 打开主界面 | 退出 | +|------|-----------|------| +| 中文 | 打开主界面 | 退出 | +| English | Open main window | Quit | +| 日本語 | メインウィンドウを開く | 終了 | + +### 使用场景 + +托盘切换供应商无需打开主界面,适合: + +- 频繁切换供应商 +- 主窗口最小化时快速操作 +- 后台运行时管理配置 + +## 设置页面 + +设置页面分为多个 Tab: + +### 通用 Tab + +- 语言设置(中文/English/日本語) +- 主题设置(跟随系统/浅色/深色) +- 窗口行为(开机自启、关闭行为) + +### 高级 Tab + +- 配置目录设置 +- 代理服务配置 +- 故障转移设置 +- 定价配置 +- 数据导入导出 + +### 用量 Tab + +- 请求统计概览 +- 趋势图表 +- 请求日志 +- 供应商/模型统计 + +### 关于 Tab + +- 版本信息 +- 更新检查 +- 开源协议 + +## 快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Cmd/Ctrl + ,` | 打开设置 | +| `Cmd/Ctrl + F` | 搜索供应商 | +| `Esc` | 关闭弹窗/搜索 | + +## 搜索功能 + +按 `Cmd/Ctrl + F` 打开搜索框: + +- 支持按名称、备注、URL 搜索 +- 实时过滤供应商列表 +- 按 `Esc` 关闭搜索 diff --git a/docs/user-manual/1-getting-started/1.4-quickstart.md b/docs/user-manual/1-getting-started/1.4-quickstart.md new file mode 100644 index 000000000..15a7b8e99 --- /dev/null +++ b/docs/user-manual/1-getting-started/1.4-quickstart.md @@ -0,0 +1,92 @@ +# 1.4 快速上手 + +本节帮助你在 5 分钟内完成首次配置。 + +## 第一步:添加供应商 + +1. 点击主界面右上角的 **+** 按钮 +2. 在「预设」下拉框中选择你的供应商 + - 常用预设:智谱 GLM、MiniMax、DeepSeek、Kimi、PackyCode + - 或选择「自定义」手动配置 +3. 填写 **API Key** +4. 点击「添加」 + +![image-20260108002807657](../assets/image-20260108002807657.png) + +> 💡 **提示**:预设会自动填充端点地址,你只需要填写 API Key。 + +## 第二步:切换供应商 + +添加完成后,供应商会出现在列表中。 + +**方式一:主界面切换** +- 点击供应商卡片的「启用」按钮 + +**方式二:托盘快速切换** +- 右键系统托盘图标 +- 直接点击供应商名称 + +## 第三步:生效方式 + +切换供应商后,各 CLI 工具的生效方式不同: + +| 应用 | 生效方式 | +|------|----------| +| Claude Code | ✅ 即时生效(支持热重载) | +| Codex | 需关闭并重新打开终端 | +| Gemini | ✅ 即时生效(每次请求重新读取配置) | + +### Claude Code 首次安装提示 + +如果 Claude Code 首次启动时提示需要**登录**或显示初始化引导,请在 CC Switch 中开启「跳过 Claude Code 初次安装确认」选项: + +1. 打开 CC Switch「设置 → 通用」 +2. 开启「跳过 Claude Code 初次安装确认」开关 +3. 重新启动 Claude Code + +![image-20260108002626389](../assets/image-20260108002626389.png) + +> ⚠️ **注意**:此选项会写入 `~/.claude/settings.json` 的 `skipIntroduction` 字段,跳过官方的新手引导流程。 + +## 验证配置 + +重启后,启动对应的 CLI 工具并输入简单的问题进行测试: + +```bash +# Claude Code - 启动后输入测试问题 +claude +> 你好,请简单介绍一下自己 + +# Codex - 启动后输入测试问题 +codex +> 你好,请简单介绍一下自己 + +# Gemini - 启动后输入测试问题 +gemini +> 你好,请简单介绍一下自己 +``` + +如果 AI 能正常回复,说明配置成功。 + +## 下一步 + +恭喜!你已经完成了基础配置。接下来可以: + +- [添加更多供应商](../2-providers/2.1-add.md) - 配置多个供应商方便切换 +- [配置 MCP 服务器](../3-extensions/3.1-mcp.md) - 扩展 AI 工具的能力 +- [设置系统提示词](../3-extensions/3.2-prompts.md) - 自定义 AI 的行为 +- [开启代理服务](../4-proxy/4.1-service.md) - 监控用量和自动故障转移 + +## 常见问题 + +### 切换后不生效? + +确保重启了终端或 CLI 工具。配置文件在切换时已经更新,但运行中的程序不会自动重新加载。 + +### 找不到预设? + +如果你的供应商不在预设列表中,选择「自定义」手动配置。参考 [添加供应商](../2-providers/2.1-add.md) 了解配置格式。 + +### 如何恢复官方登录? + +选择「官方登录」预设(Claude/Codex)或「Google 官方」预设(Gemini),重启客户端后按登录流程操作。 diff --git a/docs/user-manual/1-getting-started/1.5-settings.md b/docs/user-manual/1-getting-started/1.5-settings.md new file mode 100644 index 000000000..9c73e8aca --- /dev/null +++ b/docs/user-manual/1-getting-started/1.5-settings.md @@ -0,0 +1,134 @@ +# 1.5 个性化配置 + +本节介绍如何根据个人偏好配置 CC Switch。 + +## 打开设置 + +- 点击左上角 **⚙️** 按钮 +- 或使用快捷键 `Cmd/Ctrl + ,` + +## 语言设置 + +CC Switch 支持三种语言: + +| 语言 | 说明 | +|------|------| +| 简体中文 | 默认语言 | +| English | 英文界面 | +| 日本語 | 日文界面 | + +切换语言后立即生效,无需重启。 + +## 主题设置 + +| 选项 | 说明 | +|------|------| +| 跟随系统 | 自动匹配系统的深色/浅色模式 | +| 浅色 | 始终使用浅色主题 | +| 深色 | 始终使用深色主题 | + +## 窗口行为 + +### 开机自启 + +开启后,系统启动时自动运行 CC Switch。 + +- **Windows**:通过注册表实现 +- **macOS**:通过 LaunchAgent 实现 +- **Linux**:通过 XDG autostart 实现 + +### 关闭行为 + +| 选项 | 说明 | +|------|------| +| 最小化到托盘 | 点击关闭按钮时隐藏到系统托盘 | +| 直接退出 | 点击关闭按钮时完全退出应用 | + +推荐使用「最小化到托盘」,方便通过托盘快速切换供应商。 + +### Claude 插件集成 + +开启后,CC Switch 在切换供应商时会自动同步配置到 VS Code 中的 Claude Code 插件(写入 `~/.claude/config.json` 的 `primaryApiKey`)。 + +> 💡 **使用场景**:如果你同时使用 Claude Code CLI 和 VS Code 插件,开启此选项可以保持两者配置一致。 + +### 跳过 Claude 引导 + +开启后,跳过 Claude Code 的新手引导流程,适合已熟悉 Claude Code 的用户。 + +> ⚠️ **注意**:此选项会写入 `~/.claude/settings.json` 的 `skipIntroduction` 字段。 + +## 目录配置 + +### 应用配置目录 + +CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`。 + +### CLI 工具目录 + +可以自定义各 CLI 工具的配置目录: + +| 配置 | 默认值 | 说明 | +|------|--------|------| +| Claude 目录 | `~/.claude/` | Claude Code 配置目录 | +| Codex 目录 | `~/.codex/` | Codex 配置目录 | +| Gemini 目录 | `~/.gemini/` | Gemini CLI 配置目录 | + +> ⚠️ **注意**:修改目录后需要重启应用,且对应的 CLI 工具也需要配置相同的目录。 + +## 数据管理 + +### 导出配置 + +点击「导出」按钮,保存包含以下内容的备份文件: + +- 所有供应商配置 +- MCP 服务器配置 +- Prompts 预设 +- 应用设置 + +备份文件格式为 JSON,可以用文本编辑器查看。 + +### 导入配置 + +1. 点击「选择文件」 +2. 选择之前导出的备份文件 +3. 点击「导入」 +4. 确认覆盖现有配置 + +> ⚠️ **注意**:导入会覆盖现有配置,建议先导出当前配置作为备份。 + +## 关于页面 + +设置 → 关于 Tab + +### 版本信息 + +显示当前 CC Switch 版本号,支持: +- 查看发布说明 +- 检查更新 +- 下载并安装新版本 + +### 本地环境检查 + +自动检测已安装的 CLI 工具版本: + +| 工具 | 检测内容 | +|------|----------| +| Claude | 当前版本、最新版本 | +| Codex | 当前版本、最新版本 | +| Gemini | 当前版本、最新版本 | + +点击「刷新」按钮可重新检测。 + +### 一键安装命令 + +提供快速安装/更新 CLI 工具的命令: + +```bash +npm i -g @anthropic-ai/claude-code@latest +npm i -g @openai/codex@latest +npm i -g @google/gemini-cli@latest +``` + +点击「复制」按钮可复制到剪贴板。 diff --git a/docs/user-manual/2-providers/2.1-add.md b/docs/user-manual/2-providers/2.1-add.md new file mode 100644 index 000000000..12425a4d8 --- /dev/null +++ b/docs/user-manual/2-providers/2.1-add.md @@ -0,0 +1,320 @@ +# 2.1 添加供应商 + +## 打开添加面板 + +点击主界面右上角的 **+** 按钮,打开添加供应商面板。 + +面板分为两个 Tab: +- **应用专属供应商**:仅用于当前选中的应用(Claude/Codex/Gemini/OpenCode) +- **统一供应商**:跨应用共享的配置 + +## 使用预设添加 + +预设是预先配置好的供应商模板,只需填写 API Key 即可使用。 + +### 操作步骤 + +1. 在「预设」下拉框中选择供应商 +2. 名称和端点会自动填充 +3. 填写你的 **API Key** +4. (可选)填写备注 +5. 点击「添加」 + +### 常用预设 + +#### Claude 预设 + +| 预设名称 | 说明 | +|----------|------| +| Claude 官方 | 使用 Anthropic 官方账号登录 | +| DeepSeek | DeepSeek 模型 | +| 智谱 GLM | 智谱 AI 的 GLM 模型 | +| 智谱 GLM en | 智谱 AI(英文版) | +| 百炼 | 阿里云百炼(通义千问) | +| Kimi | Moonshot Kimi 模型 | +| Kimi For Coding | Kimi 编程专用模型 | +| ModelScope | 魔搭社区 | +| KAT-Coder | KAT-Coder 模型 | +| Longcat | Longcat AI | +| MiniMax | MiniMax 模型 | +| MiniMax en | MiniMax(英文版) | +| DouBaoSeed | 豆包 Seed 模型 | +| BaiLing | 百灵 AI | +| AiHubMix | AiHubMix 聚合服务 | +| SiliconFlow | 硅基流动 | +| SiliconFlow en | 硅基流动(英文版) | +| DMXAPI | DMXAPI 中转服务 | +| PackyCode | PackyCode 中转服务 ⭐ | +| Cubence | Cubence 服务 | +| AIGoCode | AIGoCode 服务 | +| RightCode | RightCode 服务 | +| AICodeMirror | AICodeMirror 服务 | +| OpenRouter | 聚合路由服务 | +| Nvidia | Nvidia AI 服务 | +| Xiaomi MiMo | 小米 MiMo 模型 | + +> ⭐ 标注为官方合作伙伴。预设列表可能随版本更新,以应用内实际显示为准。 + +#### Codex 预设 + +| 预设名称 | 说明 | +|----------|------| +| OpenAI 官方 | 使用 OpenAI 官方账号登录 | +| Azure OpenAI | Azure OpenAI 服务 | +| AiHubMix | AiHubMix 聚合服务 | +| DMXAPI | DMXAPI 中转服务 | +| PackyCode | PackyCode 中转服务 | +| Cubence | Cubence 服务 | +| AIGoCode | AIGoCode 服务 | +| RightCode | RightCode 服务 | +| AICodeMirror | AICodeMirror 服务 | +| OpenRouter | 聚合路由服务 | + +#### Gemini 预设 + +| 预设名称 | 说明 | +|----------|------| +| Google 官方 | 使用 Google OAuth 登录 | +| PackyCode | PackyCode 中转服务 | +| Cubence | Cubence 服务 | +| AIGoCode | AIGoCode 服务 | +| AICodeMirror | AICodeMirror 服务 | +| OpenRouter | 聚合路由服务 | +| 自定义 | 手动配置所有参数 | + +#### OpenCode 预设 + +| 预设名称 | 说明 | +|----------|------| +| DeepSeek | DeepSeek 模型 | +| 智谱 GLM | 智谱 AI 的 GLM 模型 | +| 智谱 GLM en | 智谱 AI(英文版) | +| 百炼 | 阿里云百炼 | +| Kimi k2.5 | Moonshot Kimi-k2.5 模型 | +| Kimi For Coding | Kimi 编程专用模型 | +| ModelScope | 魔搭社区 | +| KAT-Coder | KAT-Coder 模型 | +| Longcat | Longcat AI | +| MiniMax | MiniMax 模型 | +| MiniMax en | MiniMax(英文版) | +| DouBaoSeed | 豆包 Seed 模型 | +| BaiLing | 百灵 AI | +| Xiaomi MiMo | 小米 MiMo 模型 | +| AiHubMix | AiHubMix 聚合服务 | +| DMXAPI | DMXAPI 中转服务 | +| OpenRouter | 聚合路由服务 | +| Nvidia | Nvidia AI 服务 | +| PackyCode | PackyCode 中转服务 | +| Cubence | Cubence 服务 | +| AIGoCode | AIGoCode 服务 | +| RightCode | RightCode 服务 | +| AICodeMirror | AICodeMirror 服务 | +| OpenAI Compatible | OpenAI 兼容接口 | +| Oh My OpenCode | Oh My OpenCode 服务 | + +> 💡 预设列表持续更新中,以应用内实际显示为准。 + +## 自定义配置 + +选择「自定义」预设后,需要手动编辑 JSON 配置。 + +### Claude 配置格式 + +```json +{ + "env": { + "ANTHROPIC_API_KEY": "your-api-key", + "ANTHROPIC_BASE_URL": "https://api.example.com" + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `ANTHROPIC_API_KEY` | 是 | API 密钥 | +| `ANTHROPIC_BASE_URL` | 否 | 自定义端点地址 | +| `ANTHROPIC_AUTH_TOKEN` | 否 | 替代 API_KEY 的认证方式 | + +### Codex 配置格式 + +Codex 使用两个配置文件: + +**1. auth.json**(`~/.codex/auth.json`)- 存储 API 密钥: + +```json +{ + "OPENAI_API_KEY": "your-api-key" +} +``` + +**2. config.toml**(`~/.codex/config.toml`)- 存储模型和端点配置: + +```toml +# 基础配置 +model_provider = "custom" +model = "gpt-5.2" +model_reasoning_effort = "high" +disable_response_storage = true + +# 自定义供应商配置 +[model_providers.custom] +name = "custom" +base_url = "https://api.example.com/v1" +wire_api = "responses" +requires_openai_auth = true +``` + +**auth.json 字段说明**: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `OPENAI_API_KEY` | 是 | API 密钥 | + +**config.toml 字段说明**: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `model_provider` | 是 | 模型提供商名称(需与 `[model_providers.xxx]` 匹配) | +| `model` | 是 | 使用的模型(如 `gpt-5.2`、`gpt-4o`) | +| `model_reasoning_effort` | 否 | 推理强度:`low` / `medium` / `high` | +| `disable_response_storage` | 否 | 是否禁用响应存储 | +| `base_url` | 是 | API 端点地址 | +| `wire_api` | 否 | API 协议类型(通常为 `responses`) | +| `requires_openai_auth` | 否 | 是否使用 OpenAI 认证方式 | + + +### Gemini 配置格式 + +```json +{ + "env": { + "GEMINI_API_KEY": "your-api-key", + "GOOGLE_GEMINI_BASE_URL": "https://api.example.com" + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `GEMINI_API_KEY` | 是 | API 密钥 | +| `GOOGLE_GEMINI_BASE_URL` | 否 | 自定义端点地址 | +| `GEMINI_MODEL` | 否 | 指定模型 | + +> 💡 认证类型由 CC Switch 自动检测(PackyCode API 代理 / Google OAuth / 通用 API Key),无需手动配置。 + +## 统一供应商 + +统一供应商可以跨 Claude/Codex/Gemini/OpenCode 共享配置,适用于支持多种 API 格式的中转服务。 + +### 创建统一供应商 + +1. 切换到「统一供应商」Tab +2. 点击「添加统一供应商」 +3. 填写通用配置: + - 名称 + - API Key + - 端点地址 +4. 勾选要同步的应用(Claude/Codex/Gemini/OpenCode) +5. 保存 + +### 同步机制 + +统一供应商会自动同步到勾选的应用: + +- 修改统一供应商后,所有关联应用的配置同步更新 +- 删除统一供应商后,关联的应用配置也会删除 + +### 保存并同步 + +编辑统一供应商时,可以选择: + +| 操作 | 说明 | +|------|------| +| 保存 | 仅保存配置,不立即同步 | +| 保存并同步 | 保存配置并立即同步到所有启用的应用 | + +### 手动同步 + +如果需要手动触发同步: + +1. 在统一供应商卡片上点击「同步」按钮 +2. 确认同步操作 +3. 配置会覆盖各应用中关联的供应商 + +## 导入供应商 + +CC Switch 支持两种方式导入供应商配置: + +### 方式一:深度链接导入 + +通过 `ccswitch://` 协议链接一键导入: + +1. 点击或访问深度链接 +2. CC Switch 自动打开并显示导入确认 +3. 预览配置信息 +4. 点击「确认导入」 + +**获取深度链接**: +- 从他人分享获取 +- 使用 [在线生成工具](https://farion1231.github.io/cc-switch/deplink.html) 创建 + +### 方式二:数据库备份导入 + +从 SQL 备份文件批量导入: + +1. 打开「设置 → 高级 → 数据管理」 +2. 点击「选择文件」 +3. 选择之前导出的 `.sql` 备份文件 +4. 点击「导入」 +5. 确认覆盖现有配置 + +**导入内容**: +- 所有供应商配置 +- MCP 服务器配置 +- Prompts 预设 +- 用量日志 + +> ⚠️ **注意**:导入会覆盖现有数据库,建议先导出当前配置作为备份。导出的文件名格式为 `cc-switch-export-{时间戳}.sql`。 + +## 高级选项 + +### 自定义图标 + +点击名称左侧的图标区域,可以: + +- 选择预设图标 +- 自定义图标颜色 + +### 网站链接 + +填写供应商的官网或控制台地址,方便快速访问: + +- 点击供应商卡片的链接图标可直接打开 +- 用于查看余额、获取 API Key 等 + +### 备注 + +添加备注信息,如: + +- 账号用途(个人/工作) +- 套餐信息 +- 到期时间 + +备注会显示在供应商卡片上,也支持搜索。 + +### 端点测速 + +添加供应商后,可以对 API 端点进行速度测试: + +1. 点击供应商卡片的「测速」按钮 +2. 在测速面板中添加多个端点 URL +3. 点击「测速」执行测试 +4. 选择延迟最低的端点 + +**测速结果**: +- 🟢 绿色:延迟 < 500ms(优秀) +- 🟡 黄色:延迟 500-1000ms(一般) +- 🔴 红色:延迟 > 1000ms(较慢) + +![image-20260108005327817](../assets/image-20260108005327817.png) + diff --git a/docs/user-manual/2-providers/2.2-switch.md b/docs/user-manual/2-providers/2.2-switch.md new file mode 100644 index 000000000..6e9833512 --- /dev/null +++ b/docs/user-manual/2-providers/2.2-switch.md @@ -0,0 +1,111 @@ +# 2.2 切换供应商 + +## 主界面切换 + +在供应商列表中,点击目标供应商卡片的「启用」按钮。 + +### 切换流程 + +1. 点击「启用」按钮 +2. CC Switch 更新配置文件 +3. 卡片状态变为「当前启用」 +4. Claude/Gemini 即时生效,Codex 需重启终端 + +### 状态指示 + +| 状态 | 显示 | 说明 | +|------|------|------| +| 当前启用 | 蓝色边框 + 标签 | 配置文件中的当前供应商 | +| 代理活跃 | 绿色边框 | 代理模式下实际使用的供应商 | +| 普通 | 默认样式 | 未启用的供应商 | + +## 托盘快速切换 + +通过系统托盘可以快速切换,无需打开主界面。 + +### 操作步骤 + +1. 右键点击系统托盘的 CC Switch 图标 +2. 在菜单中找到对应应用(Claude/Codex/Gemini/OpenCode) +3. 点击要切换的供应商名称 +4. 切换完成,托盘会短暂提示 + +### 托盘菜单结构 + +![image-20260108004348993](../assets/image-20260108004348993.png) + +## 生效方式 + +### Claude Code + +**切换后即时生效**,无需重启。 + +Claude Code 支持热重载,会自动检测配置文件变更并重新加载。 + +### Codex + +切换后需要重启: +- 关闭当前终端窗口 +- 重新打开终端 + +### Gemini CLI + +**切换后即时生效**,无需重启。 + +Gemini CLI 每次请求都会重新读取 `.env` 文件。 + +## 配置文件变更 + +切换供应商时,CC Switch 会修改以下文件: + +### Claude + +``` +~/.claude/settings.json +``` + +修改内容: +```json +{ + "env": { + "ANTHROPIC_API_KEY": "新的 API Key", + "ANTHROPIC_BASE_URL": "新的端点" + } +} +``` + +### Codex + +``` +~/.codex/auth.json +~/.codex/config.toml(如有额外配置) +``` + +### Gemini + +``` +~/.gemini/.env +~/.gemini/settings.json +``` + +## 切换失败处理 + +如果切换失败,可能的原因: + +### 配置文件被锁定 + +其他程序正在使用配置文件。 + +**解决方法**:关闭正在运行的 CLI 工具,再尝试切换。 + +### 权限不足 + +没有写入配置文件的权限。 + +**解决方法**:检查配置目录的权限设置。 + +### 配置格式错误 + +供应商配置的 JSON 格式有误。 + +**解决方法**:编辑供应商,检查并修复 JSON 格式。 diff --git a/docs/user-manual/2-providers/2.3-edit.md b/docs/user-manual/2-providers/2.3-edit.md new file mode 100644 index 000000000..57c22ad01 --- /dev/null +++ b/docs/user-manual/2-providers/2.3-edit.md @@ -0,0 +1,145 @@ +# 2.3 编辑供应商 + +## 打开编辑面板 + +1. 找到要编辑的供应商卡片 +2. 鼠标悬停在卡片上,显示操作按钮 +3. 点击「编辑」按钮 + +## 可编辑内容 + +### 基本信息 + +| 字段 | 说明 | +|------|------| +| 名称 | 供应商显示名称 | +| 备注 | 附加说明信息 | +| 网站链接 | 供应商官网或控制台地址 | +| 图标 | 自定义图标和颜色 | + +### 图标自定义 + +CC Switch 提供丰富的图标自定义功能: + +#### 图标选择器 + +1. 点击图标区域打开图标选择器 +2. 使用搜索框按名称搜索图标 +3. 点击选择想要的图标 + +图标库包含常见的 AI 服务商和技术图标,支持: +- 按名称模糊搜索 +- 显示图标名称提示 +- 实时预览选中效果 + +![image-20260108004734882](../assets/image-20260108004734882.png) + +### 配置信息 + +JSON 格式的配置内容,包括: + +- API Key +- 端点地址 +- 其他环境变量 + +### 编辑当前启用的供应商 + +编辑当前启用的供应商时,有特殊的「回填」机制: + +1. 打开编辑面板时,会从 live 配置文件读取最新内容 +2. 如果你在 CLI 工具中手动修改过配置,这些修改会被同步回来 +3. 保存后,修改会写入 live 配置文件 + +这确保了 CC Switch 和 CLI 工具的配置始终同步。 + +## 修改 API Key + +编辑供应商时,可以直接在 **API Key** 输入框中修改: + +1. 点击供应商卡片的「编辑」按钮 +2. 在「API Key」输入框中输入新的密钥 +3. 点击「保存」 + +> 💡 **提示**:API Key 输入框支持显示/隐藏切换,点击右侧的眼睛图标可查看完整密钥。 + +## 修改端点地址 + +编辑供应商时,可以直接在 **端点地址** 输入框中修改: + +1. 点击供应商卡片的「编辑」按钮 +2. 在「端点地址」输入框中输入新的 URL +3. 点击「保存」 + +### 端点地址格式 + +| 应用 | 格式示例 | +|------|----------| +| Claude | `https://api.example.com` | +| Codex | `https://api.example.com/v1` | +| Gemini | `https://api.example.com` | + +## 添加自定义端点 + +供应商可以配置多个端点,用于: + +- 速度测试时测试多个地址 +- 故障转移时的备用端点 + +### 自动收集 + +添加供应商时,CC Switch 会自动从配置中提取端点地址。 + +### 手动添加 + +编辑供应商时,在「端点管理」区域可以: + +- 添加新端点 +- 删除现有端点 +- 设置默认端点 + +## JSON 编辑器 + +配置使用 JSON 格式,编辑器提供: + +- 语法高亮 +- 格式校验 +- 错误提示 + +### 常见错误 + +**缺少引号**: +```json +// ❌ 错误 +{ env: { KEY: "value" } } + +// ✅ 正确 +{ "env": { "KEY": "value" } } +``` + +**多余逗号**: +```json +// ❌ 错误 +{ "env": { "KEY": "value", } } + +// ✅ 正确 +{ "env": { "KEY": "value" } } +``` + +**未闭合括号**: +```json +// ❌ 错误 +{ "env": { "KEY": "value" } + +// ✅ 正确 +{ "env": { "KEY": "value" } } +``` + +## 保存与生效 + +1. 点击「保存」按钮 +2. 如果是当前启用的供应商,配置立即写入 live 文件 +3. 重启 CLI 工具生效 + +## 取消编辑 + +点击「取消」或按 `Esc` 键关闭编辑面板,所有修改都不会保存。 diff --git a/docs/user-manual/2-providers/2.4-sort-duplicate.md b/docs/user-manual/2-providers/2.4-sort-duplicate.md new file mode 100644 index 000000000..9e87dae4e --- /dev/null +++ b/docs/user-manual/2-providers/2.4-sort-duplicate.md @@ -0,0 +1,76 @@ +# 2.4 排序与复制 + +## 拖拽排序 + +通过拖拽调整供应商的显示顺序。 + +### 操作步骤 + +1. 将鼠标移到供应商卡片左侧的 **≡** 拖拽手柄 +2. 按住鼠标左键 +3. 上下拖动到目标位置 +4. 松开鼠标完成排序 + +### 排序用途 + +- **常用优先**:将常用的供应商放在列表顶部 +- **故障转移顺序**:排序会影响故障转移队列的默认顺序 + +## 复制供应商 + +快速创建供应商的副本,适用于: + +- 基于现有配置创建变体 +- 备份当前配置 +- 创建测试用配置 + +### 操作步骤 + +1. 鼠标悬停在供应商卡片上,显示操作按钮 +2. 点击「复制」按钮 +3. 自动创建副本,名称后缀 `copy` +4. 编辑副本修改配置 + +### 复制内容 + +复制会创建完整的副本,包括: + +| 内容 | 是否复制 | +|------|----------| +| 名称 | ✅ 复制(添加 `copy` 后缀) | +| 配置 | ✅ 完整复制 | +| 备注 | ✅ 复制 | +| 网站链接 | ✅ 复制 | +| 图标 | ✅ 复制 | +| 端点列表 | ✅ 复制 | +| 排序位置 | ✅ 插入到原供应商下方 | + +### 复制后编辑 + +复制完成后,通常需要修改: + +1. **名称**:改为有意义的名称 +2. **API Key**:如果是不同账号 +3. **端点**:如果是不同服务 + +## 删除供应商 + +### 操作步骤 + +1. 鼠标悬停在供应商卡片上,显示操作按钮 +2. 点击「删除」按钮 +3. 确认删除 + +### 删除确认 + +删除前会弹出确认对话框,显示: + +- 供应商名称 +- 删除后无法恢复的提示 + +### 删除限制 + +- **当前启用的供应商**:可以删除,但建议先切换到其他供应商 +- **统一供应商**:删除后,关联的应用配置也会被删除 + +![image-20260108004946288](../assets/image-20260108004946288.png) diff --git a/docs/user-manual/2-providers/2.5-usage-query.md b/docs/user-manual/2-providers/2.5-usage-query.md new file mode 100644 index 000000000..d811ae92b --- /dev/null +++ b/docs/user-manual/2-providers/2.5-usage-query.md @@ -0,0 +1,181 @@ +# 2.5 用量查询 + +## 功能说明 + +用量查询功能允许你配置自定义脚本,实时查询供应商的剩余额度、已用量等信息。 + +**使用场景**: +- 查看 API 账户剩余余额 +- 监控套餐使用情况 +- 多套餐额度汇总显示 + +## 打开配置 + +1. 鼠标悬停在供应商卡片上,显示操作按钮 +2. 点击「用量查询」按钮(📊 图标) +3. 打开用量查询配置面板 + +## 启用用量查询 + +在配置面板顶部,开启「启用用量查询」开关。 + +## 预设模板 + +CC Switch 提供三种预设模板: + +### 自定义模板 + +完全自定义请求和提取逻辑,适用于特殊 API 格式。 + +### 通用模板 + +适用于大多数标准 API 格式的供应商: + +```javascript +({ + request: { + url: "{{baseUrl}}/user/balance", + method: "GET", + headers: { + "Authorization": "Bearer {{apiKey}}", + "User-Agent": "cc-switch/1.0" + } + }, + extractor: function(response) { + return { + isValid: response.is_active || true, + remaining: response.balance, + unit: "USD" + }; + } +}) +``` + +**配置参数**: +| 参数 | 说明 | +|------|------| +| API Key | 用于认证的密钥(可选,留空则使用供应商配置的 Key) | +| Base URL | API 基础地址(可选,留空则使用供应商端点) | + +### New API 模板 + +专为 New API 类型的中转服务设计: + +```javascript +({ + request: { + url: "{{baseUrl}}/api/user/self", + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer {{accessToken}}", + "New-Api-User": "{{userId}}" + }, + }, + extractor: function (response) { + if (response.success && response.data) { + return { + planName: response.data.group || "默认套餐", + remaining: response.data.quota / 500000, + used: response.data.used_quota / 500000, + total: (response.data.quota + response.data.used_quota) / 500000, + unit: "USD", + }; + } + return { + isValid: false, + invalidMessage: response.message || "查询失败" + }; + }, +}) +``` + +**配置参数**: +| 参数 | 说明 | +|------|------| +| Base URL | New API 服务地址 | +| Access Token | 访问令牌 | +| User ID | 用户 ID | + +## 通用配置 + +### 超时时间 + +请求超时时间(秒),默认 10 秒。 + +### 自动查询间隔 + +自动刷新用量数据的间隔(分钟): +- 设为 `0` 表示禁用自动查询 +- 范围:0-1440 分钟(最长 24 小时) +- 仅当供应商处于「当前启用」状态时生效 + +## 提取器返回格式 + +提取器函数需要返回包含以下字段的对象: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `isValid` | boolean | 否 | 账户是否有效,默认 true | +| `invalidMessage` | string | 否 | 无效时的提示信息 | +| `remaining` | number | 是 | 剩余额度 | +| `unit` | string | 是 | 单位(如 USD、CNY、次) | +| `planName` | string | 否 | 套餐名称(支持多套餐) | +| `total` | number | 否 | 总额度 | +| `used` | number | 否 | 已使用额度 | +| `extra` | object | 否 | 额外信息 | + +## 测试脚本 + +配置完成后,点击「测试脚本」按钮验证: + +1. 发送请求到配置的 URL +2. 执行提取器函数 +3. 显示返回结果或错误信息 + +## 显示效果 + +配置成功后,供应商卡片上会显示: + +- **单套餐**:直接显示剩余额度 +- **多套餐**:显示套餐数量,点击展开查看详情 + +## 变量占位符 + +脚本中可使用以下占位符,运行时自动替换: + +| 占位符 | 说明 | +|--------|------| +| `{{apiKey}}` | 配置的 API Key | +| `{{baseUrl}}` | 配置的 Base URL | +| `{{accessToken}}` | 配置的 Access Token(New API) | +| `{{userId}}` | 配置的 User ID(New API) | + +## 常见供应商配置示例 + +### 故障排除 + +### 查询失败 + +**检查**: +1. API Key 是否正确 +2. Base URL 是否正确 +3. 网络是否可访问 +4. 超时时间是否足够 + +### 返回数据为空 + +**检查**: +1. 提取器函数是否有 `return` 语句 +2. 响应数据结构是否与提取器匹配 +3. 使用「测试脚本」查看原始响应 + +### 格式化失败 + +脚本语法错误时,点击「格式化」按钮会提示错误位置。 + +## 注意事项 + +- 用量查询会消耗少量 API 请求配额 +- 建议设置合理的自动查询间隔,避免频繁请求 +- 敏感信息(API Key、Token)会安全存储在本地 diff --git a/docs/user-manual/3-extensions/3.1-mcp.md b/docs/user-manual/3-extensions/3.1-mcp.md new file mode 100644 index 000000000..8ee7c3cb0 --- /dev/null +++ b/docs/user-manual/3-extensions/3.1-mcp.md @@ -0,0 +1,207 @@ +# 3.1 MCP 服务器管理 + +## 什么是 MCP + +MCP (Model Context Protocol) 是一种协议,允许 AI 工具访问外部数据源和工具。通过 MCP 服务器,你可以让 AI: + +- 访问文件系统 +- 执行网络请求 +- 查询数据库 +- 调用外部 API + +## 打开 MCP 面板 + +点击顶部导航栏的 **MCP** 按钮。 + +## 面板概览 + +![image-20260108005723522](../assets/image-20260108005723522.png) + +## 添加 MCP 服务器 + +### 使用预设模板 + +1. 点击右上角 **+** 按钮 +2. 在「预设」下拉框中选择模板 +3. 根据需要修改配置 +4. 点击「保存」 + +![image-20260108005739731](../assets/image-20260108005739731.png) + +### 常用预设 + +| 预设 | 包名 | 功能说明 | +|------|------|----------| +| fetch | mcp-server-fetch | HTTP 请求工具,让 AI 能够获取网页内容 | +| time | @modelcontextprotocol/server-time | 时间工具,提供当前时间信息 | +| memory | @modelcontextprotocol/server-memory | 记忆工具,让 AI 能够存储和检索信息 | +| sequential-thinking | @modelcontextprotocol/server-sequential-thinking | 思维链工具,增强 AI 推理能力 | +| context7 | @upstash/context7-mcp | 文档搜索工具,查询技术文档 | + +### 自定义配置 + +选择「自定义」后,需要填写: + +| 字段 | 必填 | 说明 | +|------|------|------| +| 服务器 ID | 是 | 唯一标识符 | +| 名称 | 否 | 显示名称 | +| 描述 | 否 | 功能说明 | +| 传输类型 | 是 | stdio / http / sse | +| 命令 | 是* | stdio 类型必填 | +| 参数 | 否 | 命令行参数 | +| URL | 是* | http/sse 类型必填 | +| Headers | 否 | http/sse 类型的请求头 | +| 环境变量 | 否 | 传递给服务器的环境变量 | + +## 传输类型 + +### stdio(标准输入输出) + +最常用的类型,通过启动本地进程通信。 + +```json +{ + "command": "uvx", + "args": ["mcp-server-fetch"], + "env": {} +} +``` + +**要求**: +- 需要安装对应的命令(如 `uvx`、`npx`) +- 服务器程序需要在 PATH 中 + +### http + +通过 HTTP 协议与远程服务器通信。 + +```json +{ + "url": "http://localhost:8080/mcp" +} +``` + +### sse(Server-Sent Events) + +通过 SSE 协议与服务器通信,支持实时推送。 + +```json +{ + "url": "http://localhost:8080/sse" +} +``` + +## 应用绑定 + +每个 MCP 服务器可以独立控制启用的应用。 + +### 开关说明 + +| 开关 | 作用 | 配置文件路径 | +|------|------|--------------| +| Claude | 同步到 Claude Code | `~/.claude.json` 的 `mcpServers` | +| Codex | 同步到 Codex | `~/.codex/config.toml` 的 `[mcp_servers]` | +| Gemini | 同步到 Gemini CLI | `~/.gemini/settings.json` 的 `mcpServers` | +| OpenCode | 同步到 OpenCode | `~/.opencode/config.json` 的 `mcpServers` | + +### 开关实现机制 + +当开启某个应用的开关时,CC Switch 会: + +1. **更新数据库**:将服务器的 `apps.claude/codex/gemini/opencode` 状态设为 `true` +2. **同步到 Live 配置**:将服务器配置写入对应应用的配置文件 +3. **即时生效**:下次启动 CLI 工具时自动加载新的 MCP 服务器 + +当关闭某个应用的开关时,CC Switch 会: + +1. **更新数据库**:将对应应用状态设为 `false` +2. **从 Live 配置移除**:从应用配置文件中删除该服务器 +3. **即时生效**:下次启动 CLI 工具时不再加载该 MCP 服务器 + +### 同步条件 + +MCP 服务器同步仅在对应应用已安装时执行: + +- **Claude**:需存在 `~/.claude/` 目录或 `~/.claude.json` 文件 +- **Codex**:需存在 `~/.codex/` 目录 +- **Gemini**:需存在 `~/.gemini/` 目录 +- **OpenCode**:需存在 `~/.opencode/` 目录 + +> 💡 **提示**:如果某个 CLI 工具未安装,开启对应开关不会报错,但配置不会写入。 + +关闭开关后,配置会从文件中移除。 + +## 编辑服务器 + +1. 点击服务器行右侧的「编辑」按钮 +2. 修改配置 +3. 点击「保存」 + +修改会立即同步到已启用的应用配置文件。 + +## 删除服务器 + +1. 点击服务器行右侧的「删除」按钮 +2. 确认删除 + +删除后,配置会从所有应用的配置文件中移除。 + +## 导入现有配置 + +如果你已经在 CLI 工具中配置了 MCP 服务器,可以导入到 CC Switch: + +1. 点击「导入」按钮 +2. 选择要导入的应用(Claude/Codex/Gemini/OpenCode) +3. CC Switch 会读取现有配置并导入 + +## 配置文件格式 + +### Claude (`~/.claude.json`) + +```json +{ + "mcpServers": { + "mcp-fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"] + } + } +} +``` + +### Codex (`~/.codex/config.toml`) + +```toml +[mcp_servers.mcp-fetch] +command = "uvx" +args = ["mcp-server-fetch"] +``` + +### Gemini (`~/.gemini/settings.json`) + +```json +{ + "mcpServers": { + "mcp-fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"] + } + } +} +``` + +## 常见问题 + +### 服务器启动失败 + +检查: +- 命令是否正确安装(如 `uvx`) +- 命令是否在 PATH 中 +- 参数是否正确 + +### 配置不生效 + +确保: +- 对应应用的开关已开启 +- 重启了 CLI 工具 diff --git a/docs/user-manual/3-extensions/3.2-prompts.md b/docs/user-manual/3-extensions/3.2-prompts.md new file mode 100644 index 000000000..641822906 --- /dev/null +++ b/docs/user-manual/3-extensions/3.2-prompts.md @@ -0,0 +1,158 @@ +# 3.2 Prompts 提示词管理 + +## 功能说明 + +Prompts 功能用于管理系统提示词预设。系统提示词会影响 AI 的行为和回复风格。 + +通过 CC Switch,你可以: + +- 创建多个提示词预设 +- 快速切换不同场景的提示词 +- 跨设备同步提示词配置 + +## 打开 Prompts 面板 + +点击顶部导航栏的 **Prompts** 按钮。 + +## 面板概览 + +![image-20260108010110382](../assets/image-20260108010110382.png) + +## 创建预设 + +### 操作步骤 + +1. 点击右上角 **+** 按钮 +2. 输入预设名称 +3. 在 Markdown 编辑器中编写提示词 +4. 点击「保存」 + +### Markdown 编辑器 + +编辑器提供: + +- 语法高亮 +- 实时预览 +- 常用格式快捷键 + +### 提示词编写建议 + +**结构化格式**: + +```markdown +# 角色定义 + +你是一个专业的代码审查专家。 + +## 核心能力 + +- 代码质量分析 +- 性能优化建议 +- 安全漏洞检测 + +## 回复风格 + +- 简洁明了 +- 提供具体示例 +- 给出改进建议 + +## 注意事项 + +- 不要修改业务逻辑 +- 保持代码风格一致 +``` + +## 激活预设 + +### 操作方式 + +点击预设项的开关按钮,切换启用状态。 + +### 单一激活 + +同一时间只能激活一个预设。激活新预设时,之前的预设会自动停用。 + +### 同步目标 + +激活后,提示词会写入对应应用的文件: + +| 应用 | 文件路径 | +|------|----------| +| Claude | `~/.claude/CLAUDE.md` | +| Codex | `~/.codex/AGENTS.md` | +| Gemini | `~/.gemini/GEMINI.md` | +| OpenCode | `~/.opencode/AGENTS.md` | + +## 编辑预设 + +1. 点击预设项的「编辑」按钮 +2. 修改名称或内容 +3. 点击「保存」 + +如果编辑的是当前激活的预设,保存后会立即同步到配置文件。 + +## 删除预设 + +1. 点击预设项的「删除」按钮 +2. 确认删除 + +已启用的预设不允许删除,需先停用后再删除。 + +## 智能回填 + +CC Switch 提供智能回填保护机制,确保你的手动修改不会丢失。 + +### 工作原理 + +1. 切换预设前,自动读取当前配置文件内容 +2. 比较文件内容与数据库中的预设 +3. 如果内容不同,说明用户手动修改过 +4. 将手动修改的内容保存到当前预设 +5. 然后再切换到新预设 + +### 保护场景 + +| 场景 | 处理方式 | +|------|----------| +| CLI 中直接编辑 `CLAUDE.md` | 修改自动保存到当前预设 | +| 外部编辑器修改配置文件 | 修改自动保存到当前预设 | +| 切换到其他预设 | 先保存当前修改,再切换 | + +### 技术细节 + +回填机制在以下时机触发: + +- **切换预设时**:保存当前 live 文件内容到当前预设 +- **编辑当前预设时**:从 live 文件读取最新内容 +- **首次启动时**:自动导入现有 live 文件内容 + +### 注意事项 + +- 回填仅在切换到不同预设时触发 +- 如果当前没有激活的预设,不会触发回填 +- 回填失败不会影响切换流程 + +## 跨应用使用 + +Prompts 是按应用分开管理的: + +- 切换到 Claude 时,显示 Claude 的预设 +- 切换到 Codex 时,显示 Codex 的预设 +- 切换到 Gemini 时,显示 Gemini 的预设 +- 切换到 OpenCode 时,显示 OpenCode 的预设 + +如需在多个应用使用相同的提示词,需要分别创建。 + +## 导入导出 + +### 通过深度链接分享 + +可以生成深度链接分享预设: + +``` +ccswitch://import/prompt?data= +``` + +### 通过配置导出 + +导出配置时会包含所有预设,导入后可恢复。 diff --git a/docs/user-manual/3-extensions/3.3-skills.md b/docs/user-manual/3-extensions/3.3-skills.md new file mode 100644 index 000000000..4223bc87b --- /dev/null +++ b/docs/user-manual/3-extensions/3.3-skills.md @@ -0,0 +1,199 @@ +# 3.3 Skills 技能管理 + +## 功能说明 + +Skills 是可复用的能力扩展,让 AI 工具获得特定领域的专业能力。 + +技能以文件夹形式存在,包含: +- 提示词模板 +- 工具定义 +- 示例代码 + +## 支持的应用 + +Skills 功能支持所有四种应用: +- **Claude Code** +- **Codex** +- **Gemini CLI** +- **OpenCode** + +## 打开 Skills 页面 + +点击顶部导航栏的 **Skills** 按钮。 + +> 注意:Skills 按钮在所有应用模式下均可见。 + +## 页面概览 + +![image-20260108010253926](../assets/image-20260108010253926.png) + +## 发现技能 + +### 预配置仓库 + +CC Switch 预配置了以下 GitHub 仓库: + +| 仓库 | 说明 | +|------|------| +| Anthropic 官方 | Anthropic 提供的官方技能 | +| ComposioHQ | 社区维护的技能集合 | +| 社区精选 | 精选的高质量技能 | + +![image-20260108010308060](../assets/image-20260108010308060.png) + +### 搜索过滤 + +CC Switch 提供强大的搜索和过滤功能: + +#### 搜索框 + +- 支持按技能名称搜索 +- 支持按技能描述搜索 +- 支持按目录名称搜索 +- 实时过滤,输入即搜索 + +#### 状态过滤 + +使用下拉菜单按安装状态过滤: + +| 选项 | 说明 | +|------|------| +| 全部 | 显示所有技能 | +| 已安装 | 仅显示已安装的技能 | +| 未安装 | 仅显示未安装的技能 | + +![image-20260108010324583](../assets/image-20260108010324583.png) + +#### 组合使用 + +搜索和过滤可以组合使用: +- 先选择「已安装」过滤 +- 再输入关键词搜索 +- 结果显示匹配数量 + +### 刷新列表 + +点击「刷新」按钮重新扫描仓库,获取最新技能。 + +## 安装技能 + +### 操作步骤 + +1. 找到要安装的技能卡片 +2. 点击「安装」按钮 +3. 等待安装完成 + +### 安装位置 + +| 应用 | 安装目录 | +|------|----------| +| Claude | `~/.claude/skills/` | +| Codex | `~/.codex/skills/` | +| Gemini | `~/.gemini/skills/` | +| OpenCode | `~/.opencode/skills/` | + +### 安装内容 + +安装会将技能文件夹复制到本地: + +``` +~/.claude/skills/ +└── skill-name/ + ├── README.md + ├── prompt.md + └── tools/ + └── ... +``` + +## 卸载技能 + +### 操作步骤 + +1. 找到已安装的技能卡片 +2. 点击「卸载」按钮 +3. 确认卸载 + +### 卸载效果 + +- 删除本地技能文件夹 +- 更新安装状态 + +## 仓库管理 + +### 打开仓库管理 + +点击页面顶部的「仓库管理」按钮。 + +### 添加自定义仓库 + +1. 点击「添加仓库」 +2. 填写仓库信息: + - Owner:GitHub 用户名或组织名 + - Name:仓库名称 + - Branch:分支名(默认 main) + - Subdirectory:技能所在子目录(可选) +3. 点击「添加」 + +### 仓库格式 + +``` +https://github.com/{owner}/{name}/tree/{branch}/{subdirectory} +``` + +示例: +``` +Owner: anthropics +Name: claude-skills +Branch: main +Subdirectory: skills +``` + +### 删除仓库 + +1. 在仓库列表中找到要删除的仓库 +2. 点击「删除」按钮 +3. 确认删除 + +删除仓库后,该仓库的技能不会从列表中消失,但无法再更新。 + +## 技能卡片信息 + +每个技能卡片显示: + +| 信息 | 说明 | +|------|------| +| 名称 | 技能名称 | +| 描述 | 功能说明 | +| 来源 | 所属仓库 | +| 状态 | 已安装 / 未安装 | + +## 技能更新 + +目前不支持自动更新。如需更新技能: + +1. 卸载现有技能 +2. 刷新列表 +3. 重新安装 + +### 技能列表为空 + +可能原因: +- 网络问题,无法访问 GitHub +- 仓库配置错误 + +解决方法: +- 检查网络连接 +- 点击「刷新」重试 +- 检查仓库配置 + +### 安装失败 + +可能原因: +- 网络问题 +- 磁盘空间不足 +- 权限问题 + +解决方法: +- 检查网络连接 +- 检查磁盘空间 +- 检查目录权限 diff --git a/docs/user-manual/4-proxy/4.1-service.md b/docs/user-manual/4-proxy/4.1-service.md new file mode 100644 index 000000000..49666f21d --- /dev/null +++ b/docs/user-manual/4-proxy/4.1-service.md @@ -0,0 +1,222 @@ +# 4.1 代理服务 + +## 功能说明 + +代理服务在本地启动一个 HTTP 代理,所有 API 请求都通过代理转发。 + +**主要用途**: +- 记录请求日志 +- 统计 API 用量 +- 支持故障转移 +- 集中管理多个应用的请求 + +## 启动代理 + +### 方式一:主界面开关 + +点击主界面顶部的 **代理开关** 按钮。 + +开关状态: +- 🔴 白色:代理未运行 +- 🟢 绿色:代理运行中 + +![image-20260108011353927](../assets/image-20260108011353927.png) + +### 方式二:设置页面 + +1. 打开「设置 → 高级 → 代理服务」 +2. 点击右上角的开关 + +![image-20260108011338922](../assets/image-20260108011338922.png) + +## 代理配置 + +### 基础配置 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| 监听地址 | 代理绑定的 IP 地址 | `127.0.0.1` | +| 监听端口 | 代理监听的端口 | `15721` | +| 启用日志 | 是否记录请求日志 | 开启 | + +### 修改配置 + +1. **停止代理服务**(必须先停止) +2. 修改监听地址或端口 +3. 点击「保存」 +4. 重新启动代理 + +> ⚠️ 修改地址/端口需要先停止代理服务 + +### 监听地址说明 + +| 地址 | 说明 | +|------|------| +| `127.0.0.1` | 仅本机可访问(推荐) | +| `0.0.0.0` | 允许局域网访问 | + +## 运行状态 + +代理运行时,面板显示以下信息: + +### 服务地址 + +``` +http://127.0.0.1:15721 +``` + +点击「复制」按钮可复制地址。 + +### 当前供应商 + +显示各应用当前使用的供应商: + +``` +Claude: PackyCode +Codex: AIGoCode +Gemini: Google 官方 +``` + +### 统计数据 + +| 指标 | 说明 | +|------|------| +| 活跃连接 | 当前正在处理的请求数 | +| 总请求数 | 启动以来的总请求数 | +| 成功率 | 请求成功的百分比(>90% 绿色,≤90% 黄色) | +| 运行时间 | 代理已运行的时长 | + +### 故障转移队列 + +代理面板会按应用类型显示故障转移队列: + +``` +Claude +├── 1. PackyCode [当前使用] ● +├── 2. AIGoCode ● +└── 3. 备用供应商 ○ + +Codex +├── 1. AIGoCode [当前使用] ● +└── 2. 备用供应商 ● +``` + +队列说明: +- 数字表示优先级顺序 +- 「当前使用」标签表示正在使用的供应商 +- 健康徽章显示供应商状态: + - 🟢 绿色:健康(连续失败 0 次) + - 🟡 黄色:降级(连续失败 1-2 次) + - 🔴 红色:不健康(连续失败 ≥3 次) + +## 工作原理 + +### 请求流程 + +```mermaid +sequenceDiagram + participant CLI as CLI 工具 (Claude) + participant Proxy as 本地代理 (CC Switch) + participant API as API 供应商 (Anthropic) + participant DB as 数据存储 (Logger) + + CLI->>Proxy: 发送 API 请求 + Proxy->>DB: 记录请求日志/统计用量 + Proxy->>API: 转发请求 + API-->>Proxy: 返回响应 + Proxy-->>CLI: 返回响应 +``` + +### 配置修改 + +启动代理并开启应用接管后,CC Switch 会修改应用配置: + +**Claude**: +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" + } +} +``` + +**Codex**: +```toml +base_url = "http://127.0.0.1:15721/v1" +``` + +**Gemini**: +``` +GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721 +``` + +## 停止代理 + +### 方式一:主界面开关 + +点击代理开关按钮关闭。 + +### 方式二:设置页面 + +在代理服务面板中关闭开关。 + +### 停止后的处理 + +停止代理时,CC Switch 会: + +1. 恢复应用配置到原始状态 +2. 保存请求日志 +3. 关闭所有连接 + +## 日志记录 + +### 开启日志 + +在代理面板中开启「启用日志」开关。 + +### 日志内容 + +每条请求记录包含: + +| 字段 | 说明 | +|------|------| +| 时间 | 请求时间 | +| 应用 | Claude/Codex/Gemini/OpenCode | +| 供应商 | 使用的供应商 | +| 模型 | 请求的模型 | +| Token | 输入/输出 token 数 | +| 延迟 | 请求耗时 | +| 状态 | 成功/失败 | + +### 查看日志 + +在「设置 → 用量」Tab 中查看请求日志。 + +## 常见问题 + +### 端口被占用 + +错误信息:`Address already in use` + +解决方法: +1. 更换端口(如 5001) +2. 或关闭占用端口的程序 + +### 代理启动失败 + +检查: +- 端口是否被占用 +- 是否有足够权限 +- 防火墙是否阻止 + +### 请求超时 + +可能原因: +- 网络问题 +- 供应商服务器问题 +- 代理配置错误 + +解决方法: +- 检查网络连接 +- 尝试直接访问供应商 API +- 检查供应商配置 diff --git a/docs/user-manual/4-proxy/4.2-takeover.md b/docs/user-manual/4-proxy/4.2-takeover.md new file mode 100644 index 000000000..67e8afd89 --- /dev/null +++ b/docs/user-manual/4-proxy/4.2-takeover.md @@ -0,0 +1,196 @@ +# 4.2 应用接管 + +## 功能说明 + +应用接管是指让 CC Switch 代理接管特定应用的 API 请求。 + +开启接管后: +- 应用的 API 请求会通过本地代理转发 +- 可以记录请求日志和统计用量 +- 可以使用故障转移功能 + +## 前提条件 + +使用应用接管功能前,需要先启动代理服务。 + +## 开启接管 + +### 操作位置 + +设置 → 高级 → 代理服务 → 应用接管区域 + +### 操作步骤 + +1. 确保代理服务已启动 +2. 找到「应用接管」区域 +3. 为需要的应用开启开关 + +### 接管开关 + +| 开关 | 作用 | +|------|------| +| Claude 接管 | 接管 Claude Code 的请求 | +| Codex 接管 | 接管 Codex 的请求 | +| Gemini 接管 | 接管 Gemini CLI 的请求 | +| OpenCode 接管 | 接管 OpenCode 的请求 | + +可以同时开启多个应用的接管。 + +## 接管原理 + +### 配置修改 + +开启接管后,CC Switch 会修改应用的配置文件,将 API 端点指向本地代理。 + +**Claude 配置变更**: + +```json +// 接管前 +{ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } +} + +// 接管后 +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" + } +} +``` + +**Codex 配置变更**: + +```toml +# 接管前 +base_url = "https://api.openai.com/v1" + +# 接管后 +base_url = "http://127.0.0.1:15721/v1" +``` + +**Gemini 配置变更**: + +```bash +# 接管前 +GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com + +# 接管后 +GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721 +``` + +### 请求转发 + +代理收到请求后: + +1. 识别请求来源(Claude/Codex/Gemini/OpenCode) +2. 查找该应用当前启用的供应商 +3. 将请求转发到供应商的实际端点 +4. 记录请求日志 +5. 返回响应给应用 + +## 接管状态指示 + +### 主界面指示 + +开启接管后,主界面会有以下变化: + +- **代理 Logo 颜色**:从无色变为绿色 +- **供应商卡片**:当前活跃的供应商显示绿色边框 + +### 供应商卡片状态 + +| 状态 | 边框颜色 | 说明 | +|------|----------|------| +| 当前启用 | 蓝色 | 配置文件中的供应商(非代理模式) | +| 代理活跃 | 绿色 | 代理实际使用的供应商 | +| 普通 | 默认 | 未使用的供应商 | + +## 关闭接管 + +### 操作步骤 + +1. 在代理面板中关闭对应应用的接管开关 +2. 或直接停止代理服务 + +### 配置恢复 + +关闭接管时,CC Switch 会: + +1. 将应用配置恢复到接管前的状态 +2. 保存当前的请求日志 + +## 接管与供应商切换 + +### 接管模式下切换供应商 + +在接管模式下切换供应商: + +1. 在主界面点击供应商的「启用」按钮 +2. 代理立即使用新供应商转发请求 +3. **无需重启 CLI 工具** + +这是接管模式的一大优势:切换供应商即时生效。 + +### 非接管模式下切换 + +在非接管模式下切换供应商: + +1. 修改配置文件 +2. 需要重启 CLI 工具才能生效 + +## 多应用接管 + +可以同时接管多个应用,每个应用独立管理: + +- 独立的供应商配置 +- 独立的故障转移队列 +- 独立的请求统计 + +## 使用场景 + +### 场景一:用量监控 + +开启接管 + 日志记录,监控 API 使用情况。 + +### 场景二:快速切换 + +开启接管后,切换供应商无需重启 CLI 工具。 + +### 场景三:故障转移 + +开启接管是使用故障转移功能的前提。 + +## 注意事项 + +### 性能影响 + +代理会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。 + +### 网络要求 + +接管模式下,CLI 工具需要能够访问本地代理地址。 + +### 配置备份 + +开启接管前,CC Switch 会备份原始配置,关闭时恢复。 + +## 常见问题 + +### 接管后请求失败 + +检查: +- 代理服务是否正常运行 +- 供应商配置是否正确 +- 网络是否正常 + +### 关闭接管后配置未恢复 + +可能原因: +- 代理异常退出 +- 配置文件被其他程序修改 + +解决方法: +- 手动编辑供应商,重新保存 +- 或重新启用再关闭接管 diff --git a/docs/user-manual/4-proxy/4.3-failover.md b/docs/user-manual/4-proxy/4.3-failover.md new file mode 100644 index 000000000..484dc24e0 --- /dev/null +++ b/docs/user-manual/4-proxy/4.3-failover.md @@ -0,0 +1,233 @@ +# 4.3 故障转移 + +## 功能说明 + +故障转移功能在主供应商请求失败时,自动切换到备用供应商,确保服务不中断。 + +**适用场景**: +- 供应商服务不稳定 +- 需要高可用性 +- 长时间运行的任务 + +## 前提条件 + +使用故障转移功能需要: + +1. ✅ 启动代理服务 +2. ✅ 开启应用接管 +3. ✅ 配置故障转移队列 +4. ✅ 开启自动故障转移 + +## 配置故障转移队列 + +### 打开配置页面 + +设置 → 高级 → 故障转移 + +### 选择应用 + +页面顶部有四个 Tab: +- Claude +- Codex +- Gemini +- OpenCode + +选择要配置的应用。 + +### 添加备用供应商 + +1. 在「故障转移队列」区域 +2. 点击「添加供应商」 +3. 从下拉列表选择供应商 +4. 供应商会添加到队列末尾 + +### 调整优先级 + +拖拽供应商调整顺序: +- 序号越小,优先级越高 +- 主供应商失败后,按顺序尝试备用供应商 + +### 移除供应商 + +点击供应商右侧的「移除」按钮。 + +## 主界面快捷操作 + +当代理和故障转移都开启时,供应商卡片会显示故障转移开关。 + +### 添加到队列 + +1. 找到供应商卡片 +2. 开启故障转移开关 +3. 供应商自动添加到队列 + +### 从队列移除 + +1. 关闭供应商卡片的故障转移开关 +2. 供应商从队列中移除 + +## 开启自动故障转移 + +### 操作步骤 + +1. 在故障转移配置页面 +2. 开启「自动故障转移」开关 + +### 开关说明 + +| 状态 | 行为 | +|------|------| +| 关闭 | 仅记录失败,不自动切换 | +| 开启 | 失败时自动切换到下一个供应商 | + +## 故障转移流程 + +```mermaid +graph TD + Start[请求到达代理] --> Send[发送到当前供应商] + Send --> CheckSuccess{成功?} + CheckSuccess -- 是 --> Return[返回响应] + CheckSuccess -- 否 --> LogFail[记录失败] + LogFail --> CheckCircuit{检查熔断状态} + CheckCircuit -- 熔断 --> Skip[跳过此供应商] + CheckCircuit -- 未熔断 --> IncFail[增加失败计数] + Skip --> Next{队列中下一个?} + IncFail --> Next + Next -- 有 --> Switch[切换供应商] + Switch --> Retry[重试请求] + Retry --> Send + Next -- 无 --> Error[返回错误] +``` + +## 熔断器配置 + +熔断器防止频繁重试失败的供应商。 + +### 配置项 + +不同应用有独立的默认配置。以下为通用默认值,Claude 有独立的宽松配置。 + +| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 | +|------|------|--------|--------|------| +| 失败阈值 | 连续失败多少次触发熔断 | 4 | 8 | 1-20 | +| 恢复成功阈值 | 半开状态下成功多少次后关闭熔断器 | 2 | 3 | 1-10 | +| 恢复等待时间 | 熔断后多久尝试恢复(秒) | 60 | 90 | 0-300 | +| 错误率阈值 | 错误率超过此值时打开熔断器 | 60% | 70% | 0-100% | +| 最小请求数 | 计算错误率前的最小请求数 | 10 | 15 | 5-100 | + +> 💡 Claude 由于请求耗时较长,默认配置更为宽松,容忍更多失败次数。 + +### 超时配置 + +| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 | +|------|------|--------|--------|------| +| 流式首字节超时 | 等待首个数据块的最大时间(秒) | 60 | 90 | 1-120 | +| 流式静默超时 | 数据块之间的最大间隔(秒) | 120 | 180 | 60-600(填 0 禁用) | +| 非流式超时 | 非流式请求的总超时时间(秒) | 600 | 600 | 60-1200 | + +### 重试配置 + +| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 | +|------|------|--------|--------|------| +| 最大重试次数 | 请求失败时的重试次数 | 3 | 6 | 0-10 | + +> 💡 Gemini 的默认最大重试次数为 5。 + +### 熔断状态 + +| 状态 | 说明 | +|------|------| +| 关闭 | 正常状态,允许请求 | +| 开启 | 熔断状态,跳过此供应商 | +| 半开 | 尝试恢复,发送试探请求 | + +### 状态转换 + +```mermaid +stateDiagram-v2 + [*] --> Closed: 初始化 + Closed --> Open: 失败次数 >= 阈值 + Open --> HalfOpen: 熔断时长到期 + HalfOpen --> Closed: 试探成功 (>= 恢复阈值) + HalfOpen --> Open: 试探失败 +``` + +## 健康状态指示 + +### 供应商卡片 + +卡片上显示健康状态徽章: + +| 徽章 | 状态 | 说明 | +|------|------|------| +| 🟢 | 健康 | 连续失败次数为 0 | +| 🟡 | 警告 | 有失败但未触发熔断 | +| 🔴 | 熔断 | 已触发熔断,暂时跳过 | + +### 队列列表 + +故障转移队列中也显示每个供应商的健康状态。 + +## 故障转移日志 + +每次故障转移会记录: + +| 信息 | 说明 | +|------|------| +| 时间 | 发生时间 | +| 原供应商 | 失败的供应商 | +| 新供应商 | 切换到的供应商 | +| 失败原因 | 错误信息 | + +在用量统计的请求日志中可以查看。 + +## 最佳实践 + +### 队列配置建议 + +1. **主供应商**:最稳定、最快的供应商 +2. **第一备用**:次优选择 +3. **第二备用**:保底选择 + +### 熔断器配置建议 + +| 场景 | 失败阈值 | 熔断时长 | +|------|----------|----------| +| 高可用要求 | 2 | 30 秒 | +| 一般场景 | 3 | 60 秒 | +| 容忍偶发失败 | 5 | 120 秒 | + +### 监控建议 + +定期检查: +- 各供应商的健康状态 +- 故障转移发生频率 +- 熔断触发情况 + +## 常见问题 + +### 故障转移没有触发 + +检查: +1. 代理服务是否运行 +2. 应用接管是否开启 +3. 自动故障转移是否开启 +4. 队列中是否有备用供应商 + +### 频繁触发故障转移 + +可能原因: +- 主供应商不稳定 +- 网络问题 +- 配置错误 + +解决方法: +- 检查主供应商状态 +- 调整熔断器参数 +- 考虑更换主供应商 + +### 所有供应商都熔断 + +等待熔断时长到期后自动恢复,或: +1. 手动重启代理服务 +2. 重置熔断状态 diff --git a/docs/user-manual/4-proxy/4.4-usage.md b/docs/user-manual/4-proxy/4.4-usage.md new file mode 100644 index 000000000..acf8c095a --- /dev/null +++ b/docs/user-manual/4-proxy/4.4-usage.md @@ -0,0 +1,291 @@ +# 4.4 用量统计 + +## 功能说明 + +用量统计功能记录和分析 API 请求数据,帮助你: + +- 了解 API 使用情况 +- 估算费用支出 +- 分析使用模式 +- 排查问题 + +## 前提条件 + +使用用量统计功能需要: + +1. ✅ 启动代理服务 +2. ✅ 开启应用接管 +3. ✅ 开启日志记录 + +## 打开用量统计 + +设置 → 用量 Tab + +## 统计概览 + +### 汇总卡片 + +页面顶部显示关键指标: + +| 指标 | 说明 | +|------|------| +| 总请求数 | 统计周期内的请求总数 | +| 总 Token | 输入 + 输出 Token 总数 | +| 估算费用 | 基于定价配置计算的费用 | +| 成功率 | 成功请求的百分比 | + +### 时间范围 + +可选择统计的时间范围: + +| 选项 | 范围 | +|------|------| +| 今日 | 当天 00:00 至今 | +| 最近 7 天 | 过去 7 天 | +| 最近 30 天 | 过去 30 天 | + +![image-20260108011730105](../assets/image-20260108011730105.png) + +## 趋势图表 + +### 请求趋势 + +折线图展示请求数量的变化趋势: + +- X 轴:时间 +- Y 轴:请求数量 +- 可按小时/天查看 +- 支持缩放和拖拽 + +### Token 趋势 + +展示 Token 使用量的变化: + +- 输入 Token(蓝色)- 用户发送的 prompt 内容 +- 输出 Token(绿色)- AI 生成的回复内容 +- 缓存创建 Token(橙色)- 首次创建缓存消耗的 Token +- 缓存命中 Token(紫色)- 复用缓存节省的 Token +- 成本(红色虚线,右侧 Y 轴)- 估算费用 + +> 💡 **缓存 Token 说明**:Anthropic API 支持 Prompt Caching 功能。缓存创建时收取较高费用(通常为输入价格的 1.25 倍),但后续命中缓存时只收取 0.1 倍的价格,可大幅降低重复请求的成本。 + +### 时间粒度 + +- **今日**:按小时显示(24 个数据点) +- **7 天/30 天**:按天显示 + + + +![image-20260108011742847](../assets/image-20260108011742847.png) + +## 详细数据 + +页面下方有三个数据 Tab: + +### 请求日志 + +每条请求的详细记录: + +| 字段 | 说明 | +|------|------| +| 时间 | 请求时间 | +| 供应商 | 使用的供应商名称 | +| 模型 | 请求的模型(计费模型) | +| 输入 Token | 输入的 Token 数 | +| 输出 Token | 输出的 Token 数 | +| 缓存读取 | 缓存命中的 Token 数 | +| 缓存创建 | 缓存创建的 Token 数 | +| 总费用 | 估算费用(美元) | +| 耗时信息 | 请求耗时、首 Token 时间、流式/非流式 | +| 状态 | HTTP 状态码 | + +#### 耗时信息说明 + +耗时信息列显示多个徽章: + +| 徽章 | 说明 | 颜色规则 | +|------|------|----------| +| 总耗时 | 请求总时长(秒) | ≤5s 绿色,≤120s 橙色,>120s 红色 | +| 首 Token | 流式请求首个 Token 时间 | ≤5s 绿色,≤120s 橙色,>120s 红色 | +| 流式/非流式 | 请求类型 | 流式蓝色,非流式紫色 | + +#### 查看详情 + +点击请求行可查看详细信息: + +- 完整的请求参数 +- 响应内容摘要 +- 错误信息(如果失败) + +#### 筛选日志 + +支持按以下条件筛选: + +| 筛选项 | 选项 | +|--------|------| +| 应用类型 | 全部 / Claude / Codex / Gemini / OpenCode | +| 状态码 | 全部 / 200 / 400 / 401 / 429 / 500 | +| 供应商 | 文本搜索 | +| 模型 | 文本搜索 | +| 时间范围 | 开始时间 - 结束时间(日期时间选择器) | + +操作按钮: +- **搜索**:应用筛选条件 +- **重置**:恢复默认(过去 24 小时) +- **刷新**:重新加载数据 + +![image-20260108011859974](../assets/image-20260108011859974.png) + +### 供应商统计 + +按供应商分组的统计数据: + +| 字段 | 说明 | +|------|------| +| 供应商 | 供应商名称 | +| 请求数 | 该供应商的请求总数 | +| 成功数 | 成功的请求数 | +| 失败数 | 失败的请求数 | +| 成功率 | 成功百分比 | +| 总 Token | Token 使用总量 | +| 估算费用 | 该供应商的费用 | + +![image-20260108011907928](../assets/image-20260108011907928.png) + +### 模型统计 + +按模型分组的统计数据: + +| 字段 | 说明 | +|------|------| +| 模型 | 模型名称 | +| 请求数 | 该模型的请求总数 | +| 输入 Token | 输入 Token 总量 | +| 输出 Token | 输出 Token 总量 | +| 平均延迟 | 平均响应时间 | +| 估算费用 | 该模型的费用 | + +![image-20260108011915381](../assets/image-20260108011915381.png) + +## 定价配置 + +### 打开定价配置 + +设置 → 高级 → 定价配置 + +### 配置模型价格 + +为每个模型设置价格(每百万 Token): + +| 字段 | 说明 | +|------|------| +| 模型 ID | 模型标识符(如 claude-3-sonnet) | +| 显示名称 | 自定义显示名称 | +| 输入价格 | 每百万输入 Token 的价格 | +| 输出价格 | 每百万输出 Token 的价格 | +| 缓存读取价格 | 每百万缓存命中 Token 的价格 | +| 缓存创建价格 | 每百万缓存创建 Token 的价格 | + +### 操作 + +- **添加**:点击「添加」按钮新增模型定价 +- **编辑**:点击行末的编辑图标修改 +- **删除**:点击行末的删除图标移除 + +![image-20260108011933565](../assets/image-20260108011933565.png) + +### 预设价格 + +CC Switch 预设了常用模型的官方价格(每百万 Token): + +**Claude 系列(美元)**: + +| 模型 | 输入 | 输出 | 缓存读取 | 缓存创建 | +|------|------|------|----------|----------| +| **Claude 4.5 系列** | | | | | +| claude-opus-4-5 | $5 | $25 | $0.50 | $6.25 | +| claude-sonnet-4-5 | $3 | $15 | $0.30 | $3.75 | +| claude-haiku-4-5 | $1 | $5 | $0.10 | $1.25 | +| **Claude 4 系列** | | | | | +| claude-opus-4 | $15 | $75 | $1.50 | $18.75 | +| claude-opus-4-1 | $15 | $75 | $1.50 | $18.75 | +| claude-sonnet-4 | $3 | $15 | $0.30 | $3.75 | +| **Claude 3.5 系列** | | | | | +| claude-3-5-sonnet | $3 | $15 | $0.30 | $3.75 | +| claude-3-5-haiku | $0.80 | $4 | $0.08 | $1.00 | + +**OpenAI 系列 / Codex(美元)**: + +| 模型 | 输入 | 输出 | 缓存读取 | +|------|------|------|----------| +| **GPT-5.2 系列** | | | | +| gpt-5.2 | $1.75 | $14 | $0.175 | +| **GPT-5.1 系列** | | | | +| gpt-5.1 | $1.25 | $10 | $0.125 | +| **GPT-5 系列** | | | | +| gpt-5 | $1.25 | $10 | $0.125 | + +> 注:Codex 预设包含了 low/medium/high 等变体,价格与基础模型一致。 + +**Gemini 系列(美元)**: + +| 模型 | 输入 | 输出 | 缓存读取 | +|------|------|------|----------| +| **Gemini 3 系列** | | | | +| gemini-3-pro-preview | $2 | $12 | $0.20 | +| gemini-3-flash-preview | $0.50 | $3 | $0.05 | +| **Gemini 2.5 系列** | | | | +| gemini-2.5-pro | $1.25 | $10 | $0.125 | +| gemini-2.5-flash | $0.30 | $2.50 | $0.03 | + +**中国厂商模型(人民币)**: + +| 模型 | 输入 | 输出 | 缓存读取 | +|------|------|------|----------| +| **DeepSeek** | | | | +| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 | +| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 | +| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 | +| **Kimi (月之暗面)** | | | | +| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 | +| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 | +| kimi-k2-turbo | ¥8.00 | ¥58.00 | ¥1.00 | +| **MiniMax** | | | | +| minimax-m2.1 | ¥2.10 | ¥8.40 | ¥0.21 | +| minimax-m2.1-lightning | ¥2.10 | ¥16.80 | ¥0.21 | +| **其他** | | | | +| glm-4.7 | ¥2.00 | ¥8.00 | ¥0.40 | +| doubao-seed-code | ¥1.20 | ¥8.00 | ¥0.24 | +| mimo-v2-flash | 免费 | 免费 | - | + +### 自定义价格 + +如果使用中转服务,价格可能不同: + +1. 点击「编辑」按钮 +2. 修改价格 +3. 保存 + +## 常见问题 + +### 统计数据为空 + +检查: +- 代理服务是否运行 +- 应用接管是否开启 +- 日志记录是否开启 +- 是否有请求通过代理 + +### 费用估算不准确 + +可能原因: +- 定价配置与实际不符 +- 使用了中转服务的特殊定价 + +解决方法: +- 更新定价配置 +- 参考供应商的实际账单 + +### Token 数量与供应商不一致 + +CC Switch 使用自己的方式估算 Token 数,可能与供应商的计算方式略有差异。以供应商账单为准。 diff --git a/docs/user-manual/4-proxy/4.5-model-test.md b/docs/user-manual/4-proxy/4.5-model-test.md new file mode 100644 index 000000000..b3a98522a --- /dev/null +++ b/docs/user-manual/4-proxy/4.5-model-test.md @@ -0,0 +1,156 @@ +# 4.5 模型检查 + +## 功能说明 + +模型检查功能用于验证供应商配置的模型是否可用,通过发送实际的 API 请求来测试: + +- 模型是否存在 +- API Key 是否有效 +- 端点是否正常响应 +- 响应延迟是否正常 + +## 打开配置 + +设置 → 高级 → 模型测试 + +## 测试模型配置 + +为每个应用配置用于测试的模型: + +| 应用 | 配置项 | 默认值 | 说明 | +|------|--------|--------|------| +| Claude | Claude 模型 | 系统默认 | 建议使用 Haiku 系列(成本低、速度快) | +| Codex | Codex 模型 | 系统默认 | 建议使用 mini 系列 | +| Gemini | Gemini 模型 | 系统默认 | 建议使用 Flash 系列 | + +### 模型选择建议 + +选择测试模型时考虑: + +1. **成本**:选择价格较低的模型(如 Haiku、Mini、Flash) +2. **速度**:选择响应快的模型 +3. **可用性**:选择供应商支持的模型 + +## 检查参数配置 + +### 超时时间 + +| 参数 | 说明 | 默认值 | 范围 | +|------|------|--------|------| +| 超时时间 | 单次请求超时 | 45 秒 | 10-120 秒 | + +设置过短可能导致误判,设置过长会延迟故障检测。 + +### 重试次数 + +| 参数 | 说明 | 默认值 | 范围 | +|------|------|--------|------| +| 最大重试 | 失败后重试次数 | 2 次 | 0-5 次 | + +网络不稳定时建议增加重试次数。 + +### 降级阈值 + +| 参数 | 说明 | 默认值 | 范围 | +|------|------|--------|------| +| 降级阈值 | 响应超过此时间标记为降级 | 6000ms | 1000-30000ms | + +超过阈值的供应商会被标记为「降级」状态,但仍可使用。 + +## 执行模型检查 + +### 手动测试 + +在供应商卡片上点击「测试」按钮: + +1. 发送测试请求到配置的端点 +2. 使用配置的测试模型 +3. 等待响应或超时 +4. 显示测试结果 + +### 测试内容 + +测试请求会: +- 发送简短的 prompt(如 "Hi") +- 限制最大输出 token(通常 10-50) +- 使用流式响应检测首字节时间 + +## 测试结果 + +### 健康状态 + +| 状态 | 图标 | 说明 | +|------|------|------| +| 健康 | 🟢 | 响应正常,延迟在阈值内 | +| 降级 | 🟡 | 响应正常,但延迟超过阈值 | +| 不可用 | 🔴 | 请求失败或超时 | + +### 结果信息 + +测试完成后显示: +- 响应延迟(毫秒) +- 首字节时间(TTFB) +- 错误信息(如果失败) + +## 与故障转移集成 + +模型检查与故障转移功能配合使用: + +### 健康检查 + +开启代理服务后,系统会定期对故障转移队列中的供应商执行健康检查: + +1. 使用配置的测试模型发送请求 +2. 根据响应更新健康状态 +3. 不健康的供应商会被暂时跳过 + +### 熔断恢复 + +当供应商从熔断状态恢复时: + +1. 执行模型检查验证可用性 +2. 检查通过后恢复正常状态 +3. 检查失败则继续熔断 + +## 常见问题 + +### 测试失败但实际可用 + +**可能原因**: +- 测试模型与实际使用的模型不同 +- 供应商不支持配置的测试模型 + +**解决方法**: +- 修改测试模型为供应商支持的模型 +- 检查供应商的模型列表 + +### 延迟过高 + +**可能原因**: +- 网络延迟 +- 供应商服务器负载高 +- 模型响应慢 + +**解决方法**: +- 使用更快的测试模型 +- 调整降级阈值 +- 考虑使用镜像端点 + +### 频繁超时 + +**可能原因**: +- 超时时间设置过短 +- 网络不稳定 +- 供应商服务不稳定 + +**解决方法**: +- 增加超时时间 +- 增加重试次数 +- 检查网络连接 + +## 注意事项 + +- 模型检查会消耗少量 API 配额 +- 建议使用低成本模型进行测试 +- 测试频率不宜过高,避免浪费配额 +- 不同供应商支持的模型可能不同 diff --git a/docs/user-manual/5-faq/5.1-config-files.md b/docs/user-manual/5-faq/5.1-config-files.md new file mode 100644 index 000000000..4049a67a6 --- /dev/null +++ b/docs/user-manual/5-faq/5.1-config-files.md @@ -0,0 +1,278 @@ +# 5.1 配置文件说明 + +## CC Switch 数据存储 + +### 存储目录 + +默认位置:`~/.cc-switch/` + +可在设置中自定义位置(用于云同步)。 + +### 目录结构 + +``` +~/.cc-switch/ +├── cc-switch.db # SQLite 数据库 +├── settings.json # 设备级设置 +└── backups/ # 自动备份 + ├── backup-20251230-120000.json + ├── backup-20251229-180000.json + └── ... +``` + +### 数据库内容 + +`cc-switch.db` 是 SQLite 数据库,存储: + +| 表 | 内容 | +|-----|------| +| providers | 供应商配置 | +| provider_endpoints | 供应商端点候选列表 | +| mcp_servers | MCP 服务器配置 | +| prompts | 提示词预设 | +| skills | 技能安装状态 | +| skill_repos | 技能仓库配置 | +| proxy_config | 代理配置 | +| proxy_request_logs | 代理请求日志 | +| provider_health | 供应商健康状态 | +| model_pricing | 模型定价 | +| settings | 应用设置 | + +### 设备设置 + +`settings.json` 存储设备级设置: + +```json +{ + "language": "zh", + "theme": "system", + "windowBehavior": "minimize", + "autoStart": false, + "claudeConfigDir": null, + "codexConfigDir": null, + "geminiConfigDir": null, + "opencodeConfigDir": null +} +``` + +这些设置不会跨设备同步。 + +### 自动备份 + +`backups/` 目录存储自动备份: + +- 每次导入配置前自动创建 +- 保留最近 10 个备份 +- 文件名包含时间戳 + +## Claude Code 配置 + +### 配置目录 + +默认:`~/.claude/` + +### 主要文件 + +``` +~/.claude/ +├── settings.json # 主配置文件 +├── CLAUDE.md # 系统提示词 +└── skills/ # 技能目录 + └── ... +``` + +### settings.json + +```json +{ + "env": { + "ANTHROPIC_API_KEY": "sk-xxx", + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + }, + "permissions": { + "allow_file_access": true + } +} +``` + +| 字段 | 说明 | +|------|------| +| `env.ANTHROPIC_API_KEY` | API 密钥 | +| `env.ANTHROPIC_BASE_URL` | API 端点(可选) | +| `env.ANTHROPIC_AUTH_TOKEN` | 替代认证方式 | + +### MCP 配置 + +MCP 服务器配置在 `~/.claude.json`: + +```json +{ + "mcpServers": { + "mcp-fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"] + } + } +} +``` + +## Codex 配置 + +### 配置目录 + +默认:`~/.codex/` + +### 主要文件 + +``` +~/.codex/ +├── auth.json # 认证配置 +├── config.toml # 主配置 + MCP +└── AGENTS.md # 系统提示词 +``` + +### auth.json + +```json +{ + "OPENAI_API_KEY": "sk-xxx" +} +``` + +### config.toml + +```toml +# 基础配置 +base_url = "https://api.openai.com/v1" +model = "gpt-4" + +# MCP 服务器 +[mcp_servers.mcp-fetch] +command = "uvx" +args = ["mcp-server-fetch"] +``` + +## Gemini CLI 配置 + +### 配置目录 + +默认:`~/.gemini/` + +### 主要文件 + +``` +~/.gemini/ +├── .env # 环境变量(API Key) +├── settings.json # 主配置 + MCP +└── GEMINI.md # 系统提示词 +``` + +### .env + +```bash +GEMINI_API_KEY=xxx +GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com +GEMINI_MODEL=gemini-pro +``` + +### settings.json + +```json +{ + "mcpServers": { + "mcp-fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"] + } + } +} +``` + +| 字段 | 说明 | +|------|------| +| `mcpServers` | MCP 服务器配置 | + +## OpenCode 配置 + +### 配置目录 + +默认:`~/.opencode/` + +### 主要文件 + +``` +~/.opencode/ +├── config.json # 主配置文件 +├── AGENTS.md # 系统提示词 +└── skills/ # 技能目录 + └── ... +``` + +## 配置优先级 + +CC Switch 修改配置时的优先级: + +1. **CC Switch 数据库** - 单一事实源 (SSOT) +2. **Live 配置文件** - 切换供应商时写入 +3. **回填机制** - 编辑当前供应商时从 Live 文件读取 + +## 手动编辑配置 + +### 可以手动编辑 + +- CLI 工具的配置文件(会被 CC Switch 回填) +- CC Switch 的 `settings.json` + +### 不建议手动编辑 + +- `cc-switch.db` 数据库文件 +- 备份文件 + +### 编辑后同步 + +如果手动编辑了 CLI 工具的配置: + +1. 打开 CC Switch +2. 编辑对应的供应商 +3. 会看到手动修改的内容已回填 +4. 保存以同步到数据库 + +## 配置迁移 + +### 从旧版本迁移 + +CC Switch v3.7.0 从 JSON 文件迁移到 SQLite: + +- 首次启动自动迁移 +- 迁移成功后显示提示 +- 旧配置文件保留作为备份 + +### 跨设备迁移 + +1. 在源设备导出配置 +2. 在目标设备导入配置 +3. 或使用云同步功能 + +## 配置备份建议 + +### 定期备份 + +建议定期导出配置: + +1. 设置 → 高级 → 数据管理 +2. 点击「导出」 +3. 保存到安全位置 + +### 备份内容 + +导出文件包含: + +- 所有供应商配置 +- MCP 服务器配置 +- Prompts 预设 +- 应用设置 + +### 不包含的内容 + +- 用量日志(数据量大) +- 设备级设置(不适合跨设备) diff --git a/docs/user-manual/5-faq/5.2-questions.md b/docs/user-manual/5-faq/5.2-questions.md new file mode 100644 index 000000000..83084c979 --- /dev/null +++ b/docs/user-manual/5-faq/5.2-questions.md @@ -0,0 +1,220 @@ +# 5.2 常见问题 FAQ + +## 安装问题 + +### macOS 提示「未知开发者」 + +**问题**:首次打开时提示「无法打开,因为它来自身份不明的开发者」 + +**解决方法一**:通过系统设置 +1. 关闭警告弹窗 +2. 打开「系统设置」→「隐私与安全性」 +3. 找到 CC Switch 相关提示 +4. 点击「仍要打开」 +5. 再次打开应用 + +**解决方法二**:通过终端命令(推荐) +```bash +sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/ +``` + +执行后即可正常打开应用。 + +### Windows 安装后无法启动 + +**可能原因**: +- 缺少 WebView2 运行时 +- 杀毒软件拦截 + +**解决方法**: +1. 安装 [Microsoft Edge WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) +2. 将 CC Switch 添加到杀毒软件白名单 + +### Linux 启动报错 + +**问题**:AppImage 无法启动 + +**解决方法**: +```bash +# 添加执行权限 +chmod +x CC-Switch-*.AppImage + +# 如果仍然失败,尝试 +./CC-Switch-*.AppImage --no-sandbox +``` + +## 供应商问题 + +### 切换供应商后不生效 + +**原因**:CLI 工具需要重新加载配置 + +**解决方法**: +- Claude Code:关闭并重新打开终端,或重启 IDE +- Codex:关闭并重新打开终端 +- Gemini:托盘切换可即时生效,无需重启 + +### API Key 无效 + +**检查步骤**: +1. 确认 API Key 正确复制(无多余空格) +2. 确认 API Key 未过期 +3. 确认端点地址正确 +4. 使用速度测试验证连接 + +### 如何恢复官方登录 + +**操作步骤**: +1. 选择「官方登录」预设(Claude/Codex)或「Google 官方」预设(Gemini) +2. 点击「启用」 +3. 重启对应的 CLI 工具 +4. 按照 CLI 工具的登录流程操作 + +## 代理问题 + +### 代理服务启动失败 + +**可能原因**:端口被占用 + +**解决方法**: +1. 检查端口占用: + ```bash + # macOS/Linux + lsof -i :49152 + + # Windows + netstat -ano | findstr :49152 + ``` +2. 关闭占用端口的程序 +3. 或尝试修改配置恢复默认端口: + - 打开「设置 → 代理服务」 + - 点击「恢复默认」按钮 + +### 代理模式下请求超时 + +**可能原因**: +- 网络问题 +- 供应商服务器问题 +- 代理配置错误 + +**解决方法**: +1. 检查网络连接 +2. 尝试直接访问供应商 API(关闭代理) +3. 检查供应商配置是否正确 + +### 关闭代理后配置未恢复 + +**可能原因**:代理异常退出 + +**解决方法**: +1. 编辑当前供应商 +2. 检查端点地址是否正确 +3. 保存以更新配置 + +## 故障转移问题 + +### 故障转移没有触发 + +**检查清单**: +- [ ] 代理服务是否运行 +- [ ] 应用接管是否开启 +- [ ] 自动故障转移是否开启 +- [ ] 队列中是否有备用供应商 + +### 频繁触发故障转移 + +**可能原因**: +- 主供应商不稳定 +- 熔断器阈值设置过低 + +**解决方法**: +1. 检查主供应商状态 +2. 调高失败阈值(如从 3 改为 5) +3. 考虑更换主供应商 + +### 所有供应商都熔断了 + +**解决方法**: +1. 等待熔断时长到期(默认 60 秒) +2. 或重启代理服务重置状态 + +## 数据问题 + +### 配置丢失 + +**可能原因**: +- 配置目录被删除 +- 数据库损坏 + +**解决方法**: +1. 检查 `~/.cc-switch/` 目录是否存在 +2. 从备份恢复:`~/.cc-switch/backups/` +3. 或从之前导出的配置文件导入 + +### 导入配置失败 + +**可能原因**: +- 文件格式错误 +- 版本不兼容 + +**解决方法**: +1. 确认文件是 CC Switch 导出的 JSON 文件 +2. 检查文件内容是否完整 +3. 尝试用文本编辑器打开检查格式 + +### 用量统计数据为空 + +**检查清单**: +- [ ] 代理服务是否运行 +- [ ] 应用接管是否开启 +- [ ] 日志记录是否开启 +- [ ] 是否有请求通过代理 + +## 其他问题 + +### 托盘图标不显示 + +**macOS**: +- 检查系统设置中的菜单栏图标设置 + +**Windows**: +- 检查任务栏设置,确保 CC Switch 图标未被隐藏 + +**Linux**: +- 需要安装系统托盘支持(如 `libappindicator`) + +### 界面显示异常 + +**解决方法**: +1. 尝试切换主题(浅色/深色) +2. 重启应用 +3. 删除 `~/.cc-switch/settings.json` 重置设置 + +### 更新失败 + +**解决方法**: +1. 检查网络连接 +2. 手动下载最新版本安装 +3. 如使用 Homebrew:`brew upgrade --cask cc-switch` + +## 获取帮助 + +### 提交 Issue + +如果以上方法都无法解决问题: + +1. 访问 [GitHub Issues](https://github.com/farion1231/cc-switch/issues) +2. 搜索是否有类似问题 +3. 如果没有,创建新 Issue +4. 提供以下信息: + - 操作系统和版本 + - CC Switch 版本 + - 问题描述和复现步骤 + - 错误信息(如有) + +### 日志文件 + +提交 Issue 时可附上日志文件: + +- macOS/Linux:`~/.cc-switch/logs/` +- Windows:`%APPDATA%\cc-switch\logs\` diff --git a/docs/user-manual/5-faq/5.3-deeplink.md b/docs/user-manual/5-faq/5.3-deeplink.md new file mode 100644 index 000000000..32057e17a --- /dev/null +++ b/docs/user-manual/5-faq/5.3-deeplink.md @@ -0,0 +1,256 @@ +# 5.3 深度链接协议 + +## 功能说明 + +CC Switch 支持 `ccswitch://` 深度链接协议,可以通过链接一键导入配置。 + +**使用场景**: +- 团队共享配置 +- 教程中的一键配置 +- 跨设备快速同步 + +## 在线生成工具 + +CC Switch 提供在线深度链接生成工具: + +**访问地址**:[https://farion1231.github.io/cc-switch/deplink.html](https://farion1231.github.io/cc-switch/deplink.html) + +### 使用方法 + +1. 打开上述网页 +2. 选择导入类型(供应商/MCP/Prompt) +3. 填写配置信息 +4. 点击「生成链接」 +5. 复制生成的深度链接 +6. 分享给他人或在其他设备使用 + +## 协议格式 + +### V1 协议 + +使用 URL 参数格式,易读易生成: + +``` +ccswitch://v1/import?resource={type}&app={app}&name={name}&... +``` + +**通用参数**: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `resource` | 是 | 资源类型:`provider` / `mcp` / `prompt` / `skill` | +| `app` | 是 | 应用类型:`claude` / `codex` / `gemini` / `opencode` | +| `name` | 是 | 名称 | + +**供应商参数**(resource=provider): + +| 参数 | 必填 | 说明 | +|------|------|------| +| `endpoint` | 否 | API 端点地址(支持逗号分隔多个 URL) | +| `apiKey` | 否 | API 密钥 | +| `homepage` | 否 | 供应商官网 | +| `model` | 否 | 默认模型 | +| `haikuModel` | 否 | Haiku 模型(仅 Claude) | +| `sonnetModel` | 否 | Sonnet 模型(仅 Claude) | +| `opusModel` | 否 | Opus 模型(仅 Claude) | +| `notes` | 否 | 备注 | +| `icon` | 否 | 图标 | +| `config` | 否 | Base64 编码的配置内容 | +| `configFormat` | 否 | 配置格式:`json` / `toml` | +| `configUrl` | 否 | 远程配置 URL | +| `enabled` | 否 | 是否启用(布尔值) | +| `usageScript` | 否 | 用量查询脚本 | +| `usageEnabled` | 否 | 是否启用用量查询(默认 true) | +| `usageApiKey` | 否 | 用量查询专用 API Key | +| `usageBaseUrl` | 否 | 用量查询专用地址 | +| `usageAccessToken` | 否 | 用量查询访问令牌 | +| `usageUserId` | 否 | 用量查询用户 ID | +| `usageAutoInterval` | 否 | 自动查询间隔(分钟) | + +**提示词参数**(resource=prompt): + +| 参数 | 必填 | 说明 | +|------|------|------| +| `content` | 是 | 提示词内容 | +| `description` | 否 | 描述 | +| `enabled` | 否 | 是否启用(布尔值) | + +**MCP 参数**(resource=mcp): + +| 参数 | 必填 | 说明 | +|------|------|------| +| `apps` | 是 | 应用列表(逗号分隔,如 `claude,codex,gemini`) | +| `config` | 是 | MCP 服务器配置(JSON 格式) | +| `enabled` | 否 | 是否启用(布尔值) | + +**Skill 参数**(resource=skill): + +| 参数 | 必填 | 说明 | +|------|------|------| +| `repo` | 是 | 仓库(格式:`owner/name`) | +| `directory` | 否 | 目录路径 | +| `branch` | 否 | Git 分支 | + +**示例**: +``` +ccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx +``` + +## 导入类型示例 + +### 导入供应商 + +``` +ccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx +``` + +### 导入 MCP 服务器 + +``` +ccswitch://v1/import?resource=mcp&apps=claude,codex&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D&name=mcp-fetch +``` + +### 导入 Prompt 预设 + +``` +ccswitch://v1/import?resource=prompt&app=claude&name=%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5&content=%23%20%E8%A7%92%E8%89%B2%0A%E4%BD%A0%E6%98%AF%E4%B8%80%E4%B8%AA%E4%B8%93%E4%B8%9A%E7%9A%84%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5%E4%B8%93%E5%AE%B6 +``` + +### 导入 Skill + +``` +ccswitch://v1/import?resource=skill&name=my-skill&repo=owner/repo&directory=skills/my-skill&branch=main +``` + +## 生成深度链接 + +### 手动生成 + +1. 准备参数 +2. 按 V1 协议格式拼接 URL +3. URL 编码特殊字符 + +**示例**: + +```javascript +const params = new URLSearchParams({ + resource: 'provider', + app: 'claude', + name: 'My Provider', + endpoint: 'https://api.example.com', + apiKey: 'sk-xxx' +}); + +const url = `ccswitch://v1/import?${params.toString()}`; +``` + +### 在线工具 + +使用 CC Switch 官方提供的在线深度链接生成工具更方便。 + +## 使用深度链接 + +### 点击链接 + +在浏览器或其他应用中点击深度链接: + +1. 系统会询问是否打开 CC Switch +2. 确认后 CC Switch 打开 +3. 显示导入确认对话框 +4. 确认导入 + +### 导入确认 + +导入前会显示确认对话框,包含: + +- 导入类型 +- 配置预览 +- 确认/取消按钮 + +**安全提示**:只导入来自可信来源的配置。 + +## 协议注册 + +### 自动注册 + +CC Switch 安装时会自动注册 `ccswitch://` 协议。 + +### 手动注册 + +如果协议未正确注册: + +**macOS**: +重新安装应用,或运行: +```bash +/usr/bin/open -a "CC Switch" --args --register-protocol +``` + +**Windows**: +重新安装应用,或检查注册表: +``` +HKEY_CLASSES_ROOT\ccswitch +``` + +**Linux**: +检查 `.desktop` 文件中的 `MimeType` 配置。 + +## 安全考虑 + +### 敏感信息 + +深度链接中可能包含敏感信息(如 API Key): + +- 不要在公开场合分享包含 API Key 的链接 +- 分享前移除或替换敏感信息 +- 使用安全渠道传输链接 + +### 验证来源 + +导入前 CC Switch 会: + +1. 验证数据格式 +2. 显示配置预览 +3. 要求用户确认 + +### 恶意链接防护 + +CC Switch 会检查: + +- 数据格式是否合法 +- 必填字段是否完整 +- 配置值是否在合理范围 + +## 示例链接 + +### 示例:导入 Claude 供应商 + +``` +ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&apiKey=sk-xxx&endpoint=https%3A%2F%2Fapi.example.com +``` + +### 示例:导入 MCP 服务器 + +``` +ccswitch://v1/import?resource=mcp&name=mcp-fetch&apps=claude,codex,gemini&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D +``` + +## 故障排除 + +### 链接无法打开 + +**检查**: +1. CC Switch 是否已安装 +2. 协议是否正确注册 +3. 链接格式是否正确 + +### 导入失败 + +**可能原因**: +- Base64 编码错误 +- JSON 格式错误 +- 缺少必填字段 + +**解决方法**: +1. 检查原始 JSON 格式 +2. 重新进行 Base64 编码 +3. 确保所有必填字段都存在 diff --git a/docs/user-manual/5-faq/5.4-env-conflict.md b/docs/user-manual/5-faq/5.4-env-conflict.md new file mode 100644 index 000000000..5b5f07233 --- /dev/null +++ b/docs/user-manual/5-faq/5.4-env-conflict.md @@ -0,0 +1,108 @@ +# 5.4 环境变量冲突 + +## 功能说明 + +CC Switch 会自动检测系统环境变量与应用配置的冲突,避免配置被意外覆盖。 + +**检测的环境变量**: +- `ANTHROPIC_API_KEY` - Claude API 密钥 +- `ANTHROPIC_BASE_URL` - Claude API 端点 +- `OPENAI_API_KEY` - OpenAI API 密钥 +- `GEMINI_API_KEY` - Gemini API 密钥 +- 其他相关环境变量 + +## 冲突警告 + +当检测到冲突时,界面顶部会显示黄色警告横幅: + +``` +⚠️ 检测到环境变量冲突 +发现 X 个环境变量可能与 CC Switch 配置冲突 +[展开] [关闭] +``` + +## 查看冲突详情 + +点击「展开」按钮查看详细信息: + +| 字段 | 说明 | +|------|------| +| 变量名 | 环境变量名称 | +| 变量值 | 当前设置的值 | +| 来源 | 变量的来源位置 | + +### 来源类型 + +| 来源 | 说明 | +|------|------| +| 用户注册表 | Windows 用户级环境变量 | +| 系统注册表 | Windows 系统级环境变量 | +| Shell 配置 | macOS/Linux 的 shell 配置文件 | +| 系统环境 | 系统级环境变量 | + +## 处理冲突 + +### 选择要删除的变量 + +1. 勾选要删除的环境变量 +2. 或点击「全选」选择所有冲突变量 + +### 删除变量 + +1. 点击「删除选中」按钮 +2. 确认删除操作 +3. CC Switch 会自动备份并删除选中的变量 + +### 自动备份 + +删除前会自动备份: + +- 备份位置:`~/.cc-switch/env-backups/` +- 备份格式:JSON 文件 +- 包含变量名、值、来源等信息 + +## 忽略警告 + +如果确认冲突不影响使用,可以: + +1. 点击警告横幅右侧的「关闭」按钮 +2. 警告会暂时隐藏 +3. 下次启动时会重新检测 + +## 手动处理 + +如果不想通过 CC Switch 删除,可以手动处理: + +### Windows + +1. 打开「系统属性 → 高级 → 环境变量」 +2. 在用户变量或系统变量中找到冲突变量 +3. 删除或修改变量 + +### macOS / Linux + +1. 编辑 shell 配置文件(如 `~/.zshrc`、`~/.bashrc`) +2. 删除或注释掉相关的 `export` 语句 +3. 重新加载配置:`source ~/.zshrc` + +## 为什么会冲突 + +环境变量的优先级通常高于配置文件,可能导致: + +- CC Switch 设置的供应商配置被覆盖 +- API 请求发送到错误的端点 +- 使用错误的 API 密钥 + +## 最佳实践 + +1. **使用 CC Switch 管理配置**:避免在系统环境变量中设置 API 密钥 +2. **定期检查**:关注冲突警告,及时处理 +3. **备份重要变量**:删除前确认已备份 + +## 恢复已删除的变量 + +如果误删了环境变量: + +1. 找到备份文件:`~/.cc-switch/env-backups/` +2. 打开对应的 JSON 文件 +3. 手动恢复变量到系统环境 diff --git a/docs/user-manual/README.md b/docs/user-manual/README.md new file mode 100644 index 000000000..057c292df --- /dev/null +++ b/docs/user-manual/README.md @@ -0,0 +1,111 @@ +# CC Switch 用户手册 + +> Claude Code / Codex / Gemini CLI / OpenCode 全方位辅助工具 + +## 目录结构 + +``` +📚 CC Switch 用户手册 +│ +├── 1. 快速入门 +│ ├── 1.1 软件介绍 +│ ├── 1.2 安装指南 +│ ├── 1.3 界面概览 +│ ├── 1.4 快速上手 +│ └── 1.5 个性化配置 +│ +├── 2. 供应商管理 +│ ├── 2.1 添加供应商 +│ ├── 2.2 切换供应商 +│ ├── 2.3 编辑供应商 +│ ├── 2.4 排序与复制 +│ └── 2.5 用量查询 +│ +├── 3. 扩展功能 +│ ├── 3.1 MCP 服务器管理 +│ ├── 3.2 Prompts 提示词管理 +│ └── 3.3 Skills 技能管理 +│ +├── 4. 代理与高可用 +│ ├── 4.1 代理服务 +│ ├── 4.2 应用接管 +│ ├── 4.3 故障转移 +│ ├── 4.4 用量统计 +│ └── 4.5 模型检查 +│ +└── 5. 常见问题 + ├── 5.1 配置文件说明 + ├── 5.2 FAQ + ├── 5.3 深度链接协议 + └── 5.4 环境变量冲突 +``` + +## 文件列表 + +### 1. 快速入门 + +| 文件 | 内容 | +|------|------| +| [1.1-introduction.md](./1-getting-started/1.1-introduction.md) | 软件介绍、核心功能、支持平台 | +| [1.2-installation.md](./1-getting-started/1.2-installation.md) | Windows/macOS/Linux 安装指南 | +| [1.3-interface.md](./1-getting-started/1.3-interface.md) | 界面布局、导航栏、供应商卡片说明 | +| [1.4-quickstart.md](./1-getting-started/1.4-quickstart.md) | 5 分钟快速上手教程 | +| [1.5-settings.md](./1-getting-started/1.5-settings.md) | 语言、主题、目录、云同步配置 | + +### 2. 供应商管理 + +| 文件 | 内容 | +|------|------| +| [2.1-add.md](./2-providers/2.1-add.md) | 使用预设、自定义配置、统一供应商 | +| [2.2-switch.md](./2-providers/2.2-switch.md) | 主界面切换、托盘切换、生效方式 | +| [2.3-edit.md](./2-providers/2.3-edit.md) | 编辑配置、修改 API Key、回填机制 | +| [2.4-sort-duplicate.md](./2-providers/2.4-sort-duplicate.md) | 拖拽排序、复制供应商、删除 | +| [2.5-usage-query.md](./2-providers/2.5-usage-query.md) | 用量查询、剩余额度、多套餐显示 | + +### 3. 扩展功能 + +| 文件 | 内容 | +|------|------| +| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP 协议、添加服务器、应用绑定 | +| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | 创建预设、激活切换、智能回填 | +| [3.3-skills.md](./3-extensions/3.3-skills.md) | 发现技能、安装卸载、仓库管理 | + +### 4. 代理与高可用 + +| 文件 | 内容 | +|------|------| +| [4.1-service.md](./4-proxy/4.1-service.md) | 启动代理、配置项、运行状态 | +| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | 应用接管、配置修改、状态指示 | +| [4.3-failover.md](./4-proxy/4.3-failover.md) | 故障转移队列、熔断器、健康状态 | +| [4.4-usage.md](./4-proxy/4.4-usage.md) | 用量统计、趋势图表、定价配置 | +| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | 模型检查、健康检测、延迟测试 | + +### 5. 常见问题 + +| 文件 | 内容 | +|------|------| +| [5.1-config-files.md](./5-faq/5.1-config-files.md) | CC Switch 存储、CLI 配置文件格式 | +| [5.2-questions.md](./5-faq/5.2-questions.md) | 常见问题解答 | +| [5.3-deeplink.md](./5-faq/5.3-deeplink.md) | 深度链接协议、生成和使用方法 | +| [5.4-env-conflict.md](./5-faq/5.4-env-conflict.md) | 环境变量冲突检测与处理 | + +## 快速链接 + +- **新用户**:从 [1.1 软件介绍](./1-getting-started/1.1-introduction.md) 开始 +- **安装问题**:查看 [1.2 安装指南](./1-getting-started/1.2-installation.md) +- **配置供应商**:查看 [2.1 添加供应商](./2-providers/2.1-add.md) +- **使用代理**:查看 [4.1 代理服务](./4-proxy/4.1-service.md) +- **遇到问题**:查看 [5.2 FAQ](./5-faq/5.2-questions.md) + +## 版本信息 + +- 文档版本:v3.10.3 +- 最后更新:2026-02-09 +- 适用于 CC Switch v3.10.0+ + +## 贡献 + +欢迎提交 Issue 或 PR 改进文档: + +- [GitHub Issues](https://github.com/farion1231/cc-switch/issues) +- [GitHub Repository](https://github.com/farion1231/cc-switch) diff --git a/docs/user-manual/assets/image-20260108001629138.png b/docs/user-manual/assets/image-20260108001629138.png new file mode 100644 index 000000000..363bc12c5 Binary files /dev/null and b/docs/user-manual/assets/image-20260108001629138.png differ diff --git a/docs/user-manual/assets/image-20260108002153668.png b/docs/user-manual/assets/image-20260108002153668.png new file mode 100644 index 000000000..aa1936e4b Binary files /dev/null and b/docs/user-manual/assets/image-20260108002153668.png differ diff --git a/docs/user-manual/assets/image-20260108002626389.png b/docs/user-manual/assets/image-20260108002626389.png new file mode 100644 index 000000000..29997215f Binary files /dev/null and b/docs/user-manual/assets/image-20260108002626389.png differ diff --git a/docs/user-manual/assets/image-20260108002807657.png b/docs/user-manual/assets/image-20260108002807657.png new file mode 100644 index 000000000..235a98363 Binary files /dev/null and b/docs/user-manual/assets/image-20260108002807657.png differ diff --git a/docs/user-manual/assets/image-20260108004348993.png b/docs/user-manual/assets/image-20260108004348993.png new file mode 100644 index 000000000..df4bf7f94 Binary files /dev/null and b/docs/user-manual/assets/image-20260108004348993.png differ diff --git a/docs/user-manual/assets/image-20260108004734882.png b/docs/user-manual/assets/image-20260108004734882.png new file mode 100644 index 000000000..9513db871 Binary files /dev/null and b/docs/user-manual/assets/image-20260108004734882.png differ diff --git a/docs/user-manual/assets/image-20260108004946288.png b/docs/user-manual/assets/image-20260108004946288.png new file mode 100644 index 000000000..438fef1d1 Binary files /dev/null and b/docs/user-manual/assets/image-20260108004946288.png differ diff --git a/docs/user-manual/assets/image-20260108005327817.png b/docs/user-manual/assets/image-20260108005327817.png new file mode 100644 index 000000000..a0b587516 Binary files /dev/null and b/docs/user-manual/assets/image-20260108005327817.png differ diff --git a/docs/user-manual/assets/image-20260108005723522.png b/docs/user-manual/assets/image-20260108005723522.png new file mode 100644 index 000000000..1913f6fca Binary files /dev/null and b/docs/user-manual/assets/image-20260108005723522.png differ diff --git a/docs/user-manual/assets/image-20260108005739731.png b/docs/user-manual/assets/image-20260108005739731.png new file mode 100644 index 000000000..699c7304b Binary files /dev/null and b/docs/user-manual/assets/image-20260108005739731.png differ diff --git a/docs/user-manual/assets/image-20260108010110382.png b/docs/user-manual/assets/image-20260108010110382.png new file mode 100644 index 000000000..f25cd1dee Binary files /dev/null and b/docs/user-manual/assets/image-20260108010110382.png differ diff --git a/docs/user-manual/assets/image-20260108010253926.png b/docs/user-manual/assets/image-20260108010253926.png new file mode 100644 index 000000000..1136a53da Binary files /dev/null and b/docs/user-manual/assets/image-20260108010253926.png differ diff --git a/docs/user-manual/assets/image-20260108010308060.png b/docs/user-manual/assets/image-20260108010308060.png new file mode 100644 index 000000000..2bd15f59d Binary files /dev/null and b/docs/user-manual/assets/image-20260108010308060.png differ diff --git a/docs/user-manual/assets/image-20260108010324583.png b/docs/user-manual/assets/image-20260108010324583.png new file mode 100644 index 000000000..30121d16b Binary files /dev/null and b/docs/user-manual/assets/image-20260108010324583.png differ diff --git a/docs/user-manual/assets/image-20260108011338922.png b/docs/user-manual/assets/image-20260108011338922.png new file mode 100644 index 000000000..6a460e9de Binary files /dev/null and b/docs/user-manual/assets/image-20260108011338922.png differ diff --git a/docs/user-manual/assets/image-20260108011353927.png b/docs/user-manual/assets/image-20260108011353927.png new file mode 100644 index 000000000..2c4ca4fbb Binary files /dev/null and b/docs/user-manual/assets/image-20260108011353927.png differ diff --git a/docs/user-manual/assets/image-20260108011730105.png b/docs/user-manual/assets/image-20260108011730105.png new file mode 100644 index 000000000..a68974162 Binary files /dev/null and b/docs/user-manual/assets/image-20260108011730105.png differ diff --git a/docs/user-manual/assets/image-20260108011742847.png b/docs/user-manual/assets/image-20260108011742847.png new file mode 100644 index 000000000..8dcb910b0 Binary files /dev/null and b/docs/user-manual/assets/image-20260108011742847.png differ diff --git a/docs/user-manual/assets/image-20260108011859974.png b/docs/user-manual/assets/image-20260108011859974.png new file mode 100644 index 000000000..bdc00b01c Binary files /dev/null and b/docs/user-manual/assets/image-20260108011859974.png differ diff --git a/docs/user-manual/assets/image-20260108011907928.png b/docs/user-manual/assets/image-20260108011907928.png new file mode 100644 index 000000000..a4104ae69 Binary files /dev/null and b/docs/user-manual/assets/image-20260108011907928.png differ diff --git a/docs/user-manual/assets/image-20260108011915381.png b/docs/user-manual/assets/image-20260108011915381.png new file mode 100644 index 000000000..4064031d5 Binary files /dev/null and b/docs/user-manual/assets/image-20260108011915381.png differ diff --git a/docs/user-manual/assets/image-20260108011933565.png b/docs/user-manual/assets/image-20260108011933565.png new file mode 100644 index 000000000..5bc6dae0a Binary files /dev/null and b/docs/user-manual/assets/image-20260108011933565.png differ diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index e1be78f6c..4746724fa 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -9,7 +9,6 @@ use crate::codex_config; use crate::config::{self, get_claude_settings_path, ConfigStatus}; use crate::settings; -/// 获取 Claude Code 配置状态 #[tauri::command] pub async fn get_claude_config_status() -> Result { Ok(config::get_claude_config_status()) @@ -63,13 +62,11 @@ pub async fn get_config_status(app: String) -> Result { } } -/// 获取 Claude Code 配置文件路径 #[tauri::command] pub async fn get_claude_code_config_path() -> Result { Ok(get_claude_settings_path().to_string_lossy().to_string()) } -/// 获取当前生效的配置目录 #[tauri::command] pub async fn get_config_dir(app: String) -> Result { let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { @@ -82,7 +79,6 @@ pub async fn get_config_dir(app: String) -> Result { Ok(dir.to_string_lossy().to_string()) } -/// 打开配置文件夹 #[tauri::command] pub async fn open_config_folder(handle: AppHandle, app: String) -> Result { let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { @@ -104,7 +100,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result Result { 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 { let config_dir = config::get_app_config_dir(); @@ -160,7 +153,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result { Ok(true) } -/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet) #[tauri::command] pub async fn get_claude_common_config_snippet( state: tauri::State<'_, crate::store::AppState>, @@ -171,13 +163,11 @@ pub async fn get_claude_common_config_snippet( .map_err(|e| e.to_string()) } -/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet) #[tauri::command] pub async fn set_claude_common_config_snippet( snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { - // 验证是否为有效的 JSON(如果不为空) if !snippet.trim().is_empty() { serde_json::from_str::(&snippet).map_err(invalid_json_format_error)?; } @@ -195,7 +185,6 @@ pub async fn set_claude_common_config_snippet( Ok(()) } -/// 获取通用配置片段(统一接口) #[tauri::command] pub async fn get_common_config_snippet( app_type: String, @@ -207,14 +196,12 @@ pub async fn get_common_config_snippet( .map_err(|e| e.to_string()) } -/// 设置通用配置片段(统一接口) #[tauri::command] pub async fn set_common_config_snippet( app_type: String, snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { - // 验证格式(根据应用类型) if !snippet.trim().is_empty() { match app_type.as_str() { "claude" => { @@ -242,6 +229,11 @@ pub async fn set_common_config_snippet( "codex" => { // TOML 格式,暂不验证(前端验证) } + "omo" => { + // 验证 JSON 格式 + serde_json::from_str::(&snippet) + .map_err(invalid_json_format_error)?; + } _ => {} } } @@ -256,14 +248,20 @@ pub async fn set_common_config_snippet( .db .set_config_snippet(&app_type, value) .map_err(|e| e.to_string())?; + + if app_type == "omo" + && state + .db + .get_current_omo_provider("opencode") + .map_err(|e| e.to_string())? + .is_some() + { + crate::services::OmoService::write_config_to_file(state.inner()) + .map_err(|e| e.to_string())?; + } Ok(()) } -/// 提取通用配置片段 -/// -/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。 -/// -/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。 #[tauri::command] pub async fn extract_common_config_snippet( appType: String, diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 8e6da2bb4..db229776b 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -324,6 +324,7 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { home.join(".local/bin"), // Native install (official recommended) home.join(".npm-global/bin"), home.join("n/bin"), // n version manager + home.join(".volta/bin"), // Volta package manager ]; #[cfg(target_os = "macos")] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ff81fde2e..7976c4316 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ mod global_proxy; mod import_export; mod mcp; mod misc; +mod omo; mod plugin; mod prompt; mod provider; @@ -26,6 +27,7 @@ pub use global_proxy::*; pub use import_export::*; pub use mcp::*; pub use misc::*; +pub use omo::*; pub use plugin::*; pub use prompt::*; pub use provider::*; diff --git a/src-tauri/src/commands/omo.rs b/src-tauri/src/commands/omo.rs new file mode 100644 index 000000000..9b3b4be47 --- /dev/null +++ b/src-tauri/src/commands/omo.rs @@ -0,0 +1,50 @@ +use tauri::State; + +use crate::services::omo::OmoLocalFileData; +use crate::services::OmoService; +use crate::store::AppState; + +#[tauri::command] +pub async fn read_omo_local_file() -> Result { + OmoService::read_local_file().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result { + let provider = state + .db + .get_current_omo_provider("opencode") + .map_err(|e| e.to_string())?; + Ok(provider.map(|p| p.id).unwrap_or_default()) +} + +#[tauri::command] +pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), String> { + let providers = state + .db + .get_all_providers("opencode") + .map_err(|e| e.to_string())?; + for (id, p) in &providers { + if p.category.as_deref() == Some("omo") { + state + .db + .clear_omo_provider_current("opencode", id) + .map_err(|e| e.to_string())?; + } + } + OmoService::delete_config_file().map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result { + let providers = state + .db + .get_all_providers("opencode") + .map_err(|e| e.to_string())?; + let count = providers + .values() + .filter(|p| p.category.as_deref() == Some("omo")) + .count(); + Ok(count) +} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index b1b94a742..47de928f5 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -8,7 +8,6 @@ use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, Spee use crate::store::AppState; use std::str::FromStr; -/// 获取所有供应商 #[tauri::command] pub fn get_providers( state: State<'_, AppState>, @@ -18,14 +17,12 @@ pub fn get_providers( 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 { 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>, @@ -36,7 +33,6 @@ pub fn add_provider( ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string()) } -/// 更新供应商 #[tauri::command] pub fn update_provider( state: State<'_, AppState>, @@ -47,7 +43,6 @@ pub fn update_provider( ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string()) } -/// 删除供应商 #[tauri::command] pub fn delete_provider( state: State<'_, AppState>, @@ -60,17 +55,18 @@ pub fn delete_provider( .map_err(|e| e.to_string()) } -/// Remove provider from live config only (for additive mode apps like OpenCode) -/// Does NOT delete from database - provider remains in the list #[tauri::command] -pub fn remove_provider_from_live_config(app: String, id: String) -> Result { +pub fn remove_provider_from_live_config( + state: tauri::State<'_, AppState>, + app: String, + id: String, +) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - ProviderService::remove_from_live_config(app_type, &id) + ProviderService::remove_from_live_config(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) } @@ -108,14 +104,12 @@ pub fn import_default_config_test_hook( import_default_config_internal(state, app_type) } -/// 导入当前配置为默认供应商 #[tauri::command] pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; import_default_config_internal(&state, app_type).map_err(Into::into) } -/// 查询供应商用量 #[allow(non_snake_case)] #[tauri::command] pub async fn queryProviderUsage( @@ -129,7 +123,6 @@ pub async fn queryProviderUsage( .map_err(|e| e.to_string()) } -/// 测试用量脚本(使用当前编辑器中的脚本,不保存) #[allow(non_snake_case)] #[allow(clippy::too_many_arguments)] #[tauri::command] @@ -162,14 +155,12 @@ pub async fn testUsageScript( .map_err(|e| e.to_string()) } -/// 读取当前生效的配置内容 #[tauri::command] pub fn read_live_provider_settings(app: String) -> Result { 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, @@ -180,7 +171,6 @@ pub async fn test_api_endpoints( .map_err(|e| e.to_string()) } -/// 获取自定义端点列表 #[tauri::command] pub fn get_custom_endpoints( state: State<'_, AppState>, @@ -192,7 +182,6 @@ pub fn get_custom_endpoints( .map_err(|e| e.to_string()) } -/// 添加自定义端点 #[tauri::command] pub fn add_custom_endpoint( state: State<'_, AppState>, @@ -205,7 +194,6 @@ pub fn add_custom_endpoint( .map_err(|e| e.to_string()) } -/// 删除自定义端点 #[tauri::command] pub fn remove_custom_endpoint( state: State<'_, AppState>, @@ -218,7 +206,6 @@ pub fn remove_custom_endpoint( .map_err(|e| e.to_string()) } -/// 更新端点最后使用时间 #[tauri::command] pub fn update_endpoint_last_used( state: State<'_, AppState>, @@ -231,7 +218,6 @@ pub fn update_endpoint_last_used( .map_err(|e| e.to_string()) } -/// 更新多个供应商的排序 #[tauri::command] pub fn update_providers_sort_order( state: State<'_, AppState>, @@ -242,24 +228,16 @@ pub fn update_providers_sort_order( ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string()) } -// ============================================================================ -// 统一供应商(Universal Provider)命令 -// ============================================================================ - use crate::provider::UniversalProvider; use std::collections::HashMap; use tauri::{AppHandle, Emitter}; -/// 统一供应商同步完成事件的 payload #[derive(Clone, serde::Serialize)] pub struct UniversalProviderSyncedEvent { - /// 操作类型: "upsert" | "delete" | "sync" pub action: String, - /// 统一供应商 ID pub id: String, } -/// 发送统一供应商同步事件,通知前端刷新供应商列表 fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) { let _ = app.emit( "universal-provider-synced", @@ -270,7 +248,6 @@ fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) { ); } -/// 获取所有统一供应商 #[tauri::command] pub fn get_universal_providers( state: State<'_, AppState>, @@ -278,7 +255,6 @@ pub fn get_universal_providers( ProviderService::list_universal(state.inner()).map_err(|e| e.to_string()) } -/// 获取单个统一供应商 #[tauri::command] pub fn get_universal_provider( state: State<'_, AppState>, @@ -287,7 +263,6 @@ pub fn get_universal_provider( ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string()) } -/// 添加或更新统一供应商 #[tauri::command] pub fn upsert_universal_provider( app: AppHandle, @@ -298,13 +273,11 @@ pub fn upsert_universal_provider( let result = ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?; - // 发送事件通知前端刷新 emit_universal_provider_synced(&app, "upsert", &id); Ok(result) } -/// 删除统一供应商 #[tauri::command] pub fn delete_universal_provider( app: AppHandle, @@ -314,13 +287,11 @@ pub fn delete_universal_provider( let result = ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?; - // 发送事件通知前端刷新 emit_universal_provider_synced(&app, "delete", &id); Ok(result) } -/// 同步统一供应商到各应用(手动触发) #[tauri::command] pub fn sync_universal_provider( app: AppHandle, @@ -330,29 +301,17 @@ pub fn sync_universal_provider( let result = ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?; - // 发送事件通知前端刷新 emit_universal_provider_synced(&app, "sync", &id); Ok(result) } -// ============================================================================ -// OpenCode 专属命令 -// ============================================================================ - -/// 从 OpenCode live 配置导入供应商到数据库 -/// -/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式, -/// 用户可能已经在 opencode.json 中配置了供应商。 #[tauri::command] pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result { crate::services::provider::import_opencode_providers_from_live(state.inner()) .map_err(|e| e.to_string()) } -/// 获取 OpenCode live 配置中的供应商 ID 列表 -/// -/// 用于前端判断供应商是否已添加到 opencode.json #[tauri::command] pub fn get_opencode_live_provider_ids() -> Result, String> { crate::opencode_config::get_providers() diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index 81ac6a6c1..53ea93dfa 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -4,6 +4,7 @@ pub mod failover; pub mod mcp; +pub mod omo; pub mod prompts; pub mod providers; pub mod proxy; @@ -15,3 +16,4 @@ pub mod universal_providers; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 导出 FailoverQueueItem 供外部使用 pub use failover::FailoverQueueItem; +pub use omo::OmoGlobalConfig; diff --git a/src-tauri/src/database/dao/omo.rs b/src-tauri/src/database/dao/omo.rs new file mode 100644 index 000000000..00f4fdb34 --- /dev/null +++ b/src-tauri/src/database/dao/omo.rs @@ -0,0 +1,73 @@ +use crate::database::Database; +use crate::error::AppError; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OmoGlobalConfig { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sisyphus_agent: Option, + #[serde(default)] + pub disabled_agents: Vec, + #[serde(default)] + pub disabled_mcps: Vec, + #[serde(default)] + pub disabled_hooks: Vec, + #[serde(default)] + pub disabled_skills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub lsp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background_task: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub browser_automation_engine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub claude_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub other_fields: Option, + pub updated_at: String, +} + +impl Default for OmoGlobalConfig { + fn default() -> Self { + Self { + id: "global".to_string(), + schema_url: None, + sisyphus_agent: None, + disabled_agents: vec![], + disabled_mcps: vec![], + disabled_hooks: vec![], + disabled_skills: vec![], + lsp: None, + experimental: None, + background_task: None, + browser_automation_engine: None, + claude_code: None, + other_fields: None, + updated_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +impl Database { + pub fn get_omo_global_config(&self) -> Result { + let json_str = self.get_setting("common_config_omo")?; + match json_str { + Some(s) => serde_json::from_str::(&s) + .map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))), + None => Ok(OmoGlobalConfig::default()), + } + } + + pub fn save_omo_global_config(&self, config: &OmoGlobalConfig) -> Result<(), AppError> { + let json_str = serde_json::to_string(config) + .map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?; + self.set_setting("common_config_omo", &json_str)?; + Ok(()) + } +} diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 71872799a..0bf9961d2 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -1,7 +1,3 @@ -//! 供应商数据访问对象 -//! -//! 提供供应商(Provider)的 CRUD 操作。 - use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::provider::{Provider, ProviderMeta}; @@ -9,8 +5,18 @@ use indexmap::IndexMap; use rusqlite::params; use std::collections::HashMap; +type OmoProviderRow = ( + String, + String, + String, + Option, + Option, + Option, + Option, + String, +); + impl Database { - /// 获取指定应用类型的所有供应商 pub fn get_all_providers( &self, app_type: &str, @@ -66,7 +72,6 @@ impl Database { let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?; provider.id = id.clone(); - // 加载 endpoints let mut stmt_endpoints = conn.prepare( "SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -103,7 +108,6 @@ impl Database { Ok(providers) } - /// 获取当前激活的供应商 ID pub fn get_current_provider(&self, app_type: &str) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn @@ -123,7 +127,6 @@ impl Database { } } - /// 根据 ID 获取单个供应商 pub fn get_provider_by_id( &self, id: &str, @@ -174,21 +177,15 @@ impl Database { } } - /// 保存供应商(新增或更新) - /// - /// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理 - /// (add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。 pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> { let mut conn = lock_conn!(self.conn); let tx = conn .transaction() .map_err(|e| AppError::Database(e.to_string()))?; - // 处理 meta:取出 endpoints 以便单独处理 let mut meta_clone = provider.meta.clone().unwrap_or_default(); let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); - // 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue) let existing: Option<(bool, bool)> = tx .query_row( "SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2", @@ -202,7 +199,6 @@ impl Database { existing.unwrap_or((false, provider.in_failover_queue)); if is_update { - // 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE tx.execute( "UPDATE providers SET name = ?1, @@ -241,7 +237,6 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; } else { - // 新增模式:使用 INSERT tx.execute( "INSERT INTO providers ( id, app_type, name, settings_config, website_url, category, @@ -268,7 +263,6 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; - // 只有新增时才同步 endpoints for (url, endpoint) in endpoints { tx.execute( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) @@ -283,7 +277,6 @@ impl Database { Ok(()) } - /// 删除供应商 pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { let conn = lock_conn!(self.conn); conn.execute( @@ -294,21 +287,18 @@ impl Database { Ok(()) } - /// 设置当前供应商 pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { let mut conn = lock_conn!(self.conn); let tx = conn .transaction() .map_err(|e| AppError::Database(e.to_string()))?; - // 重置所有为 0 tx.execute( "UPDATE providers SET is_current = 0 WHERE app_type = ?1", params![app_type], ) .map_err(|e| AppError::Database(e.to_string()))?; - // 设置新的当前供应商 tx.execute( "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2", params![id, app_type], @@ -319,7 +309,6 @@ impl Database { Ok(()) } - /// 更新供应商的 settings_config(仅更新配置,不改变其他字段) pub fn update_provider_settings_config( &self, app_type: &str, @@ -341,7 +330,6 @@ impl Database { Ok(()) } - /// 添加自定义端点 pub fn add_custom_endpoint( &self, app_type: &str, @@ -357,7 +345,6 @@ impl Database { Ok(()) } - /// 移除自定义端点 pub fn remove_custom_endpoint( &self, app_type: &str, @@ -372,4 +359,126 @@ impl Database { .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } + + pub fn set_omo_provider_current( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + tx.execute( + "UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'", + params![app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + let updated = tx + .execute( + "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'", + params![provider_id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + if updated != 1 { + return Err(AppError::Database(format!( + "Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'" + ))); + } + tx.commit().map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn is_omo_provider_current( + &self, + app_type: &str, + provider_id: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + match conn.query_row( + "SELECT is_current FROM providers + WHERE id = ?1 AND app_type = ?2 AND category = 'omo'", + params![provider_id, app_type], + |row| row.get(0), + ) { + Ok(is_current) => Ok(is_current), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + pub fn clear_omo_provider_current( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "UPDATE providers SET is_current = 0 + WHERE id = ?1 AND app_type = ?2 AND category = 'omo'", + params![provider_id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn get_current_omo_provider(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let row_data: Result = conn.query_row( + "SELECT id, name, settings_config, category, created_at, sort_index, notes, meta + FROM providers + WHERE app_type = ?1 AND category = 'omo' AND is_current = 1 + LIMIT 1", + params![app_type], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }, + ); + + let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) = + match row_data { + Ok(v) => v, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(AppError::Database(e.to_string())), + }; + + let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| { + AppError::Database(format!( + "Failed to parse OMO provider settings_config (provider_id={id}): {e}" + )) + })?; + let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() { + crate::provider::ProviderMeta::default() + } else { + serde_json::from_str(&meta_str).map_err(|e| { + AppError::Database(format!( + "Failed to parse OMO provider meta (provider_id={id}): {e}" + )) + })? + }; + + Ok(Some(Provider { + id, + name, + settings_config, + website_url: None, + category, + created_at, + sort_index, + notes, + meta: Some(meta), + icon: None, + icon_color: None, + in_failover_queue: false, + })) + } } diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 2ba3ed591..41c26e978 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -33,6 +33,7 @@ mod tests; // DAO 类型导出供外部使用 pub use dao::FailoverQueueItem; +pub use dao::OmoGlobalConfig; use crate::config::get_app_config_dir; use crate::error::AppError; diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 33a3f6c74..bca3c2de3 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -919,7 +919,16 @@ impl Database { /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> { let pricing_data = [ - // Claude 4.5 系列 (Latest Models) + // Claude 4.6 系列 + ( + "claude-opus-4-6-20260206", + "Claude Opus 4.6", + "5", + "25", + "0.50", + "6.25", + ), + // Claude 4.5 系列 ( "claude-opus-4-5-20251101", "Claude Opus 4.5", @@ -1025,6 +1034,40 @@ impl Database { "0.175", "0", ), + // GPT-5.3 Codex 系列 + ("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"), + ( + "gpt-5.3-codex-low", + "GPT-5.3 Codex", + "1.75", + "14", + "0.175", + "0", + ), + ( + "gpt-5.3-codex-medium", + "GPT-5.3 Codex", + "1.75", + "14", + "0.175", + "0", + ), + ( + "gpt-5.3-codex-high", + "GPT-5.3 Codex", + "1.75", + "14", + "0.175", + "0", + ), + ( + "gpt-5.3-codex-xhigh", + "GPT-5.3 Codex", + "1.75", + "14", + "0.175", + "0", + ), // GPT-5.1 系列 ("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"), ("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"), @@ -1212,7 +1255,7 @@ impl Database { for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data { conn.execute( - "INSERT OR REPLACE INTO model_pricing ( + "INSERT OR IGNORE INTO model_pricing ( model_id, display_name, input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", @@ -1239,14 +1282,8 @@ impl Database { } fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> { - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0)) - .map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?; - - if count == 0 { - Self::seed_model_pricing(conn)?; - } - Ok(()) + // 每次启动都执行 INSERT OR IGNORE,增量追加新模型,已有数据不覆盖 + Self::seed_model_pricing(conn) } // --- 辅助方法 --- diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 9d8c622f1..4c5790654 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -52,6 +52,8 @@ pub enum AppError { }, #[error("数据库错误: {0}")] Database(String), + #[error("OMO 配置文件不存在")] + OmoConfigNotFound, #[error("所有供应商已熔断,无可用渠道")] AllProvidersCircuitOpen, #[error("未配置供应商")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b563329ee..57366b0b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -504,6 +504,28 @@ pub fn run() { Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"), } + // 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入) + { + let has_omo = app_state + .db + .get_all_providers("opencode") + .map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo"))) + .unwrap_or(false); + if !has_omo { + match crate::services::OmoService::import_from_local(&app_state) { + Ok(provider) => { + log::info!("✓ Imported OMO config from local as provider '{}'", provider.name); + } + Err(AppError::OmoConfigNotFound) => { + log::debug!("○ No OMO config to import"); + } + Err(e) => { + log::warn!("✗ Failed to import OMO config from local: {e}"); + } + } + } + } + // 3. 导入 MCP 服务器配置(表空时触发) if app_state.db.is_mcp_table_empty().unwrap_or(false) { log::info!("MCP table empty, importing from live configurations..."); @@ -959,6 +981,10 @@ pub fn run() { commands::scan_local_proxies, // Window theme control commands::set_window_theme, + commands::read_omo_local_file, + commands::get_current_omo_provider_id, + commands::get_omo_provider_count, + commands::disable_current_omo, ]); let app = builder diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index d14daf9b5..f79fcbc4b 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -1,26 +1,3 @@ -//! OpenCode 配置文件读写模块 -//! -//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。 -//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。 -//! -//! ## 配置文件格式 -//! -//! ```json -//! { -//! "$schema": "https://opencode.ai/config.json", -//! "provider": { -//! "my-provider": { -//! "npm": "@ai-sdk/openai-compatible", -//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" }, -//! "models": { "gpt-4o": { "name": "GPT-4o" } } -//! } -//! }, -//! "mcp": { -//! "my-server": { "type": "local", "command": ["..."] } -//! } -//! } -//! ``` - use crate::config::write_json_file; use crate::error::AppError; use crate::provider::OpenCodeProviderConfig; @@ -29,52 +6,29 @@ use indexmap::IndexMap; use serde_json::{json, Map, Value}; use std::path::PathBuf; -// ============================================================================ -// Path Functions -// ============================================================================ - -/// 获取 OpenCode 配置目录 -/// -/// 默认路径: `~/.config/opencode/` -/// 可通过 settings.opencode_config_dir 覆盖 pub fn get_opencode_dir() -> PathBuf { if let Some(override_dir) = get_opencode_override_dir() { return override_dir; } - // 所有平台统一使用 ~/.config/opencode dirs::home_dir() .map(|h| h.join(".config").join("opencode")) .unwrap_or_else(|| PathBuf::from(".config").join("opencode")) } -/// 获取 OpenCode 配置文件路径 -/// -/// 返回 `~/.config/opencode/opencode.json` pub fn get_opencode_config_path() -> PathBuf { get_opencode_dir().join("opencode.json") } -/// 获取 OpenCode 环境变量文件路径(如果存在) -/// -/// 返回 `~/.config/opencode/.env` #[allow(dead_code)] pub fn get_opencode_env_path() -> PathBuf { get_opencode_dir().join(".env") } -// ============================================================================ -// Core Read/Write Functions -// ============================================================================ - -/// 读取 OpenCode 配置文件 -/// -/// 返回完整的配置 JSON 对象 pub fn read_opencode_config() -> Result { let path = get_opencode_config_path(); if !path.exists() { - // Return empty config with schema return Ok(json!({ "$schema": "https://opencode.ai/config.json" })); @@ -84,23 +38,14 @@ pub fn read_opencode_config() -> Result { serde_json::from_str(&content).map_err(|e| AppError::json(&path, e)) } -/// 写入 OpenCode 配置文件(原子写入) -/// -/// 使用临时文件 + 重命名确保原子性 pub fn write_opencode_config(config: &Value) -> Result<(), AppError> { let path = get_opencode_config_path(); - // 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况) write_json_file(&path, config)?; log::debug!("OpenCode config written to {path:?}"); Ok(()) } -// ============================================================================ -// Provider Functions (Untyped - for raw JSON operations) -// ============================================================================ - -/// 获取所有供应商配置(原始 JSON) pub fn get_providers() -> Result, AppError> { let config = read_opencode_config()?; Ok(config @@ -110,7 +55,6 @@ pub fn get_providers() -> Result, AppError> { .unwrap_or_default()) } -/// 设置供应商配置(原始 JSON) pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> { let mut full_config = read_opencode_config()?; @@ -128,7 +72,6 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> { write_opencode_config(&full_config) } -/// 删除供应商配置 pub fn remove_provider(id: &str) -> Result<(), AppError> { let mut config = read_opencode_config()?; @@ -139,11 +82,6 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } -// ============================================================================ -// Provider Functions (Typed - using OpenCodeProviderConfig) -// ============================================================================ - -/// 获取所有供应商配置(类型化) pub fn get_typed_providers() -> Result, AppError> { let providers = get_providers()?; let mut result = IndexMap::new(); @@ -155,7 +93,6 @@ pub fn get_typed_providers() -> Result, } Err(e) => { log::warn!("Failed to parse provider '{id}': {e}"); - // Skip invalid providers but continue } } } @@ -163,17 +100,11 @@ pub fn get_typed_providers() -> Result, Ok(result) } -/// 设置供应商配置(类型化) pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> { let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?; set_provider(id, value) } -// ============================================================================ -// MCP Functions -// ============================================================================ - -/// 获取所有 MCP 服务器配置 pub fn get_mcp_servers() -> Result, AppError> { let config = read_opencode_config()?; Ok(config @@ -183,7 +114,6 @@ pub fn get_mcp_servers() -> Result, AppError> { .unwrap_or_default()) } -/// 设置 MCP 服务器配置 pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> { let mut full_config = read_opencode_config()?; @@ -198,7 +128,6 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> { write_opencode_config(&full_config) } -/// 删除 MCP 服务器配置 pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { let mut config = read_opencode_config()?; @@ -208,3 +137,57 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } + +pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> { + let mut config = read_opencode_config()?; + + let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut()); + + match plugins { + Some(arr) => { + if plugin_name.starts_with("oh-my-opencode") + && !plugin_name.starts_with("oh-my-opencode-slim") + { + arr.retain(|v| { + v.as_str() + .map(|s| !s.starts_with("oh-my-opencode-slim")) + .unwrap_or(true) + }); + } + + let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name)); + if !already_exists { + arr.push(Value::String(plugin_name.to_string())); + } + } + None => { + config["plugin"] = json!([plugin_name]); + } + } + + write_opencode_config(&config) +} + +pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> { + let mut config = read_opencode_config()?; + + if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) { + arr.retain(|v| { + v.as_str() + .map(|s| { + if !s.starts_with(prefix) { + return true; // Keep: doesn't match prefix at all + } + let rest = &s[prefix.len()..]; + rest.starts_with('-') + }) + .unwrap_or(true) + }); + + if arr.is_empty() { + config.as_object_mut().map(|obj| obj.remove("plugin")); + } + } + + write_opencode_config(&config) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 07abe16e4..0875fdeb3 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod config; pub mod env_checker; pub mod env_manager; pub mod mcp; +pub mod omo; pub mod prompt; pub mod provider; pub mod proxy; @@ -12,6 +13,7 @@ pub mod usage_stats; pub use config::ConfigService; pub use mcp::McpService; +pub use omo::OmoService; pub use prompt::PromptService; pub use provider::{ProviderService, ProviderSortUpdate}; pub use proxy::ProxyService; diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs new file mode 100644 index 000000000..45ed9479b --- /dev/null +++ b/src-tauri/src/services/omo.rs @@ -0,0 +1,437 @@ +use crate::config::write_json_file; +use crate::database::OmoGlobalConfig; +use crate::error::AppError; +use crate::opencode_config::get_opencode_dir; +use crate::store::AppState; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OmoLocalFileData { + pub agents: Option, + pub categories: Option, + pub other_fields: Option, + pub global: OmoGlobalConfig, + pub file_path: String, + pub last_modified: Option, +} + +type OmoProfileData = (Option, Option, Option, bool); + +pub struct OmoService; + +impl OmoService { + fn config_path() -> PathBuf { + get_opencode_dir().join("oh-my-opencode.jsonc") + } + + fn resolve_local_config_path() -> Result { + let config_path = Self::config_path(); + if config_path.exists() { + return Ok(config_path); + } + + let json_path = config_path.with_extension("json"); + if json_path.exists() { + return Ok(json_path); + } + + Err(AppError::OmoConfigNotFound) + } + + fn read_jsonc_object(path: &Path) -> Result, AppError> { + let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?; + let cleaned = Self::strip_jsonc_comments(&content); + let parsed: Value = serde_json::from_str(&cleaned) + .map_err(|e| AppError::Config(format!("Failed to parse oh-my-opencode config: {e}")))?; + parsed + .as_object() + .cloned() + .ok_or_else(|| AppError::Config("Expected JSON object".to_string())) + } + + fn extract_other_fields(obj: &Map) -> Map { + const KNOWN_KEYS: [&str; 13] = [ + "$schema", + "agents", + "categories", + "sisyphus_agent", + "disabled_agents", + "disabled_mcps", + "disabled_hooks", + "disabled_skills", + "lsp", + "experimental", + "background_task", + "browser_automation_engine", + "claude_code", + ]; + + let mut other = Map::new(); + for (k, v) in obj { + if !KNOWN_KEYS.contains(&k.as_str()) { + other.insert(k.clone(), v.clone()); + } + } + other + } + + fn extract_string_array(val: &Value) -> Vec { + val.as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + } + + fn merge_global_from_obj(obj: &Map, global: &mut OmoGlobalConfig) { + if let Some(v) = obj.get("$schema") { + global.schema_url = v.as_str().map(|s| s.to_string()); + } + for (key, target) in [ + ("disabled_agents", &mut global.disabled_agents), + ("disabled_mcps", &mut global.disabled_mcps), + ("disabled_hooks", &mut global.disabled_hooks), + ("disabled_skills", &mut global.disabled_skills), + ] { + if let Some(v) = obj.get(key) { + *target = Self::extract_string_array(v); + } + } + for (key, target) in [ + ("sisyphus_agent", &mut global.sisyphus_agent), + ("lsp", &mut global.lsp), + ("experimental", &mut global.experimental), + ("background_task", &mut global.background_task), + ( + "browser_automation_engine", + &mut global.browser_automation_engine, + ), + ("claude_code", &mut global.claude_code), + ] { + if let Some(v) = obj.get(key) { + *target = Some(v.clone()); + } + } + } + + fn insert_opt_value(result: &mut Map, key: &str, value: &Option) { + if let Some(v) = value { + result.insert(key.to_string(), v.clone()); + } + } + + fn insert_string_array(result: &mut Map, key: &str, values: &[String]) { + if !values.is_empty() { + result.insert( + key.to_string(), + serde_json::to_value(values).unwrap_or(Value::Array(vec![])), + ); + } + } + + fn insert_object_entries(result: &mut Map, value: Option<&Value>) { + if let Some(Value::Object(map)) = value { + for (k, v) in map { + result.insert(k.clone(), v.clone()); + } + } + } + + pub fn delete_config_file() -> Result<(), AppError> { + let config_path = Self::config_path(); + if config_path.exists() { + std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?; + log::info!("OMO config file deleted: {config_path:?}"); + } + crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?; + Ok(()) + } + + pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> { + let global = state.db.get_omo_global_config()?; + let current_omo = state.db.get_current_omo_provider("opencode")?; + + let profile_data = current_omo.as_ref().map(|p| { + let agents = p.settings_config.get("agents").cloned(); + let categories = p.settings_config.get("categories").cloned(); + let other_fields = p.settings_config.get("otherFields").cloned(); + let use_common_config = p + .settings_config + .get("useCommonConfig") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + (agents, categories, other_fields, use_common_config) + }); + + let merged = Self::merge_config(&global, profile_data.as_ref()); + let config_path = Self::config_path(); + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + write_json_file(&config_path, &merged)?; + + crate::opencode_config::add_plugin("oh-my-opencode@latest")?; + + log::info!("OMO config written to {config_path:?}"); + Ok(()) + } + + fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value { + let mut result = Map::new(); + let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true); + + if use_common_config { + if let Some(url) = &global.schema_url { + result.insert("$schema".to_string(), Value::String(url.clone())); + } + + Self::insert_opt_value(&mut result, "sisyphus_agent", &global.sisyphus_agent); + Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents); + Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps); + Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks); + Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills); + Self::insert_opt_value(&mut result, "lsp", &global.lsp); + Self::insert_opt_value(&mut result, "experimental", &global.experimental); + Self::insert_opt_value(&mut result, "background_task", &global.background_task); + Self::insert_opt_value( + &mut result, + "browser_automation_engine", + &global.browser_automation_engine, + ); + Self::insert_opt_value(&mut result, "claude_code", &global.claude_code); + + Self::insert_object_entries(&mut result, global.other_fields.as_ref()); + } + + if let Some((agents, categories, other_fields, _)) = profile_data { + Self::insert_opt_value(&mut result, "agents", agents); + Self::insert_opt_value(&mut result, "categories", categories); + Self::insert_object_entries(&mut result, other_fields.as_ref()); + } + + Value::Object(result) + } + + pub fn import_from_local(state: &AppState) -> Result { + let actual_path = Self::resolve_local_config_path()?; + Self::import_from_path(state, &actual_path) + } + + fn import_from_path( + state: &AppState, + path: &std::path::Path, + ) -> Result { + let obj = Self::read_jsonc_object(path)?; + + let mut settings = Map::new(); + if let Some(agents) = obj.get("agents") { + settings.insert("agents".to_string(), agents.clone()); + } + if let Some(categories) = obj.get("categories") { + settings.insert("categories".to_string(), categories.clone()); + } + settings.insert("useCommonConfig".to_string(), Value::Bool(true)); + + let other = Self::extract_other_fields(&obj); + if !other.is_empty() { + settings.insert("otherFields".to_string(), Value::Object(other)); + } + + let mut global = state.db.get_omo_global_config()?; + Self::merge_global_from_obj(&obj, &mut global); + global.updated_at = chrono::Utc::now().to_rfc3339(); + state.db.save_omo_global_config(&global)?; + + let provider_id = format!("omo-{}", uuid::Uuid::new_v4()); + let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M")); + let settings_config = + serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({})); + + let provider = crate::provider::Provider { + id: provider_id, + name, + settings_config, + website_url: None, + category: Some("omo".to_string()), + created_at: Some(chrono::Utc::now().timestamp_millis()), + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + }; + + state.db.save_provider("opencode", &provider)?; + state + .db + .set_omo_provider_current("opencode", &provider.id)?; + Self::write_config_to_file(state)?; + Ok(provider) + } + + pub fn read_local_file() -> Result { + let actual_path = Self::resolve_local_config_path()?; + let metadata = std::fs::metadata(&actual_path).ok(); + let last_modified = metadata + .and_then(|m| m.modified().ok()) + .map(|t| chrono::DateTime::::from(t).to_rfc3339()); + + let obj = Self::read_jsonc_object(&actual_path)?; + + let agents = obj.get("agents").cloned(); + let categories = obj.get("categories").cloned(); + + let other = Self::extract_other_fields(&obj); + let other_fields = if other.is_empty() { + None + } else { + Some(Value::Object(other)) + }; + + let mut global = OmoGlobalConfig::default(); + Self::merge_global_from_obj(&obj, &mut global); + + Ok(OmoLocalFileData { + agents, + categories, + other_fields, + global, + file_path: actual_path.to_string_lossy().to_string(), + last_modified, + }) + } + + fn strip_jsonc_comments(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut in_string = false; + let mut escape = false; + + while let Some(&c) = chars.peek() { + if in_string { + result.push(c); + chars.next(); + if escape { + escape = false; + } else if c == '\\' { + escape = true; + } else if c == '"' { + in_string = false; + } + } else if c == '"' { + in_string = true; + result.push(c); + chars.next(); + } else if c == '/' { + chars.next(); + match chars.peek() { + Some('/') => { + chars.next(); + while let Some(&nc) = chars.peek() { + if nc == '\n' { + break; + } + chars.next(); + } + } + Some('*') => { + chars.next(); + while let Some(nc) = chars.next() { + if nc == '*' { + if let Some(&'/') = chars.peek() { + chars.next(); + break; + } + } + } + } + _ => { + result.push('/'); + } + } + } else { + result.push(c); + chars.next(); + } + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_jsonc_comments() { + let input = r#"{ + // This is a comment + "key": "value", // inline comment + /* multi + line */ + "key2": "val//ue" +}"#; + let result = OmoService::strip_jsonc_comments(input); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["key"], "value"); + assert_eq!(parsed["key2"], "val//ue"); + } + + #[test] + fn test_merge_config_empty() { + let global = OmoGlobalConfig::default(); + let merged = OmoService::merge_config(&global, None); + assert!(merged.is_object()); + } + + #[test] + fn test_merge_config_with_profile() { + let global = OmoGlobalConfig { + schema_url: Some("https://example.com/schema.json".to_string()), + disabled_agents: vec!["explore".to_string()], + ..Default::default() + }; + let agents = Some(serde_json::json!({ + "Sisyphus": { "model": "claude-opus-4-5" } + })); + let categories = None; + let other_fields = None; + let profile_data = (agents, categories, other_fields, true); + let merged = OmoService::merge_config(&global, Some(&profile_data)); + let obj = merged.as_object().unwrap(); + + assert_eq!(obj["$schema"], "https://example.com/schema.json"); + assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"])); + assert!(obj.contains_key("agents")); + assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5"); + } + + #[test] + fn test_merge_config_without_common_config() { + let global = OmoGlobalConfig { + schema_url: Some("https://example.com/schema.json".to_string()), + disabled_agents: vec!["explore".to_string()], + ..Default::default() + }; + let agents = Some(serde_json::json!({ + "Sisyphus": { "model": "claude-opus-4-5" } + })); + let categories = None; + let other_fields = None; + let profile_data = (agents, categories, other_fields, false); + let merged = OmoService::merge_config(&global, Some(&profile_data)); + let obj = merged.as_object().unwrap(); + + assert!(!obj.contains_key("$schema")); + assert!(!obj.contains_key("disabled_agents")); + assert!(obj.contains_key("agents")); + } +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index c6c0f1041..7af60349e 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -167,6 +167,12 @@ impl ProviderService { // OpenCode uses additive mode - always write to live config if matches!(app_type, AppType::OpenCode) { + // OMO providers use exclusive mode and write to dedicated config file. + if provider.category.as_deref() == Some("omo") { + // Do not auto-enable newly added OMO providers. + // Users must explicitly switch/apply an OMO provider to activate it. + return Ok(true); + } write_live_snapshot(&app_type, &provider)?; return Ok(true); } @@ -201,6 +207,15 @@ impl ProviderService { // OpenCode uses additive mode - always update in live config if matches!(app_type, AppType::OpenCode) { + if provider.category.as_deref() == Some("omo") { + let is_omo_current = state + .db + .is_omo_provider_current(app_type.as_str(), &provider.id)?; + if is_omo_current { + crate::services::OmoService::write_config_to_file(state)?; + } + return Ok(true); + } write_live_snapshot(&app_type, &provider)?; return Ok(true); } @@ -247,6 +262,35 @@ impl ProviderService { pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { // OpenCode uses additive mode - no current provider concept if matches!(app_type, AppType::OpenCode) { + let is_omo = state + .db + .get_provider_by_id(id, app_type.as_str())? + .and_then(|p| p.category) + .as_deref() + == Some("omo"); + + if is_omo { + let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?; + let omo_count = state + .db + .get_all_providers(app_type.as_str())? + .values() + .filter(|p| p.category.as_deref() == Some("omo")) + .count(); + + if omo_count <= 1 && was_current { + return Err(AppError::Message( + "无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(), + )); + } + + state.db.delete_provider(app_type.as_str(), id)?; + if was_current { + crate::services::OmoService::delete_config_file()?; + } + return Ok(()); + } + // Remove from database state.db.delete_provider(app_type.as_str(), id)?; // Also remove from live config @@ -272,10 +316,32 @@ impl ProviderService { /// Does NOT delete from database - provider remains in the list. /// This is used when user wants to "remove" a provider from active config /// but keep it available for future use. - pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> { + pub fn remove_from_live_config( + state: &AppState, + app_type: AppType, + id: &str, + ) -> Result<(), AppError> { match app_type { AppType::OpenCode => { - remove_opencode_provider_from_live(id)?; + let is_omo = state + .db + .get_provider_by_id(id, app_type.as_str())? + .and_then(|p| p.category) + .as_deref() + == Some("omo"); + + if is_omo { + state.db.clear_omo_provider_current(app_type.as_str(), id)?; + let still_has_current = + state.db.get_current_omo_provider("opencode")?.is_some(); + if still_has_current { + crate::services::OmoService::write_config_to_file(state)?; + } else { + crate::services::OmoService::delete_config_file()?; + } + } else { + remove_opencode_provider_from_live(id)?; + } } // Future: add other additive mode apps here _ => { @@ -307,6 +373,11 @@ impl ProviderService { .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; + // OMO providers are switched through their own exclusive path. + if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") { + return Self::switch_normal(state, app_type, id, &providers); + } + // Check if proxy takeover mode is active AND proxy server is actually running // Both conditions must be true to use hot-switch mode // Use blocking wait since this is a sync function @@ -378,6 +449,12 @@ impl ProviderService { .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; + if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") { + state.db.set_omo_provider_current(app_type.as_str(), id)?; + crate::services::OmoService::write_config_to_file(state)?; + return Ok(()); + } + // Backfill: Backfill current live config to current provider // Use effective current provider (validated existence) to ensure backfill targets valid provider let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?; diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index ab361a0eb..4a2e671ef 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -174,6 +174,29 @@ impl SkillService { Self } + /// 构建 Skill 文档 URL(指向仓库中的 SKILL.md 文件) + fn build_skill_doc_url(owner: &str, repo: &str, branch: &str, doc_path: &str) -> String { + format!("https://github.com/{owner}/{repo}/blob/{branch}/{doc_path}") + } + + /// 从旧 readme_url 中提取仓库内文档路径,兼容 `blob`/`tree` 两种格式 + fn extract_doc_path_from_url(url: &str) -> Option { + let marker = if url.contains("/blob/") { + "/blob/" + } else if url.contains("/tree/") { + "/tree/" + } else { + return None; + }; + + let (_, tail) = url.split_once(marker)?; + let (_, path) = tail.split_once('/')?; + if path.is_empty() { + return None; + } + Some(path.to_string()) + } + // ========== 路径管理 ========== /// 获取 SSOT 目录(~/.cc-switch/skills/) @@ -298,6 +321,8 @@ impl SkillService { let dest = ssot_dir.join(&install_name); + let mut repo_branch = skill.repo_branch.clone(); + // 如果已存在则跳过下载 if !dest.exists() { let repo = SkillRepo { @@ -308,7 +333,7 @@ impl SkillService { }; // 下载仓库 - let temp_dir = timeout( + let (temp_dir, used_branch) = timeout( std::time::Duration::from_secs(60), self.download_repo(&repo), ) @@ -324,6 +349,7 @@ impl SkillService { Some("checkNetwork"), )) })??; + repo_branch = used_branch; // 复制到 SSOT let source = temp_dir.join(&skill.directory); @@ -338,8 +364,39 @@ impl SkillService { Self::copy_dir_recursive(&source, &dest)?; let _ = fs::remove_dir_all(&temp_dir); + + // 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。 + if repo_branch != skill.repo_branch { + log::info!( + "Skill {}/{} 分支自动回退: {} -> {}", + skill.repo_owner, + skill.repo_name, + skill.repo_branch, + repo_branch + ); + } } + let doc_path = skill + .readme_url + .as_deref() + .and_then(Self::extract_doc_path_from_url) + .map(|path| { + if path.ends_with("/SKILL.md") || path == "SKILL.md" { + path + } else { + format!("{}/SKILL.md", path.trim_end_matches('/')) + } + }) + .unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/'))); + + let readme_url = Some(Self::build_skill_doc_url( + &skill.repo_owner, + &skill.repo_name, + &repo_branch, + &doc_path, + )); + // 创建 InstalledSkill 记录 let installed_skill = InstalledSkill { id: skill.key.clone(), @@ -352,8 +409,8 @@ impl SkillService { directory: install_name.clone(), repo_owner: Some(skill.repo_owner.clone()), repo_name: Some(skill.repo_name.clone()), - repo_branch: Some(skill.repo_branch.clone()), - readme_url: skill.readme_url.clone(), + repo_branch: Some(repo_branch), + readme_url, apps: SkillApps::only(current_app), installed_at: chrono::Utc::now().timestamp(), }; @@ -862,24 +919,26 @@ impl SkillService { /// 从仓库获取技能列表 async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result> { - let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo)) - .await - .map_err(|_| { - anyhow!(format_skill_error( - "DOWNLOAD_TIMEOUT", - &[ - ("owner", &repo.owner), - ("name", &repo.name), - ("timeout", "60") - ], - Some("checkNetwork"), - )) - })??; + let (temp_dir, resolved_branch) = + timeout(std::time::Duration::from_secs(60), self.download_repo(repo)) + .await + .map_err(|_| { + anyhow!(format_skill_error( + "DOWNLOAD_TIMEOUT", + &[ + ("owner", &repo.owner), + ("name", &repo.name), + ("timeout", "60") + ], + Some("checkNetwork"), + )) + })??; let mut skills = Vec::new(); let scan_dir = temp_dir.clone(); - - self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?; + let mut resolved_repo = repo.clone(); + resolved_repo.branch = resolved_branch; + self.scan_dir_recursive(&scan_dir, &scan_dir, &resolved_repo, &mut skills)?; let _ = fs::remove_dir_all(&temp_dir); @@ -907,7 +966,15 @@ impl SkillService { .to_string() }; - if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) { + let doc_path = skill_md + .strip_prefix(base_dir) + .unwrap_or(skill_md.as_path()) + .to_string_lossy() + .replace('\\', "/"); + + if let Ok(skill) = + self.build_skill_from_metadata(&skill_md, &directory, &doc_path, repo) + { skills.push(skill); } @@ -931,6 +998,7 @@ impl SkillService { &self, skill_md: &Path, directory: &str, + doc_path: &str, repo: &SkillRepo, ) -> Result { let meta = self.parse_skill_metadata(skill_md)?; @@ -940,9 +1008,11 @@ impl SkillService { name: meta.name.unwrap_or_else(|| directory.to_string()), description: meta.description.unwrap_or_default(), directory: directory.to_string(), - readme_url: Some(format!( - "https://github.com/{}/{}/tree/{}/{}", - repo.owner, repo.name, repo.branch, directory + readme_url: Some(Self::build_skill_doc_url( + &repo.owner, + &repo.name, + &repo.branch, + doc_path, )), repo_owner: repo.owner.clone(), repo_name: repo.name.clone(), @@ -994,16 +1064,21 @@ impl SkillService { } /// 下载仓库 - async fn download_repo(&self, repo: &SkillRepo) -> Result { + async fn download_repo(&self, repo: &SkillRepo) -> Result<(PathBuf, String)> { let temp_dir = tempfile::tempdir()?; let temp_path = temp_dir.path().to_path_buf(); let _ = temp_dir.keep(); - let branches = if repo.branch.is_empty() { - vec!["main", "master"] - } else { - vec![repo.branch.as_str(), "main", "master"] - }; + let mut branches = Vec::new(); + if !repo.branch.is_empty() { + branches.push(repo.branch.as_str()); + } + if !branches.contains(&"main") { + branches.push("main"); + } + if !branches.contains(&"master") { + branches.push("master"); + } let mut last_error = None; for branch in branches { @@ -1014,7 +1089,7 @@ impl SkillService { match self.download_and_extract(&url, &temp_path).await { Ok(_) => { - return Ok(temp_path); + return Ok((temp_path, branch.to_string())); } Err(e) => { last_error = Some(e); diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 759f8f2c4..d050b864a 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -272,7 +272,7 @@ impl Database { .single() .unwrap_or_else(Local::now); - let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string(); + let date = bucket_start.to_rfc3339(); if let Some(mut stat) = map.remove(&i) { stat.date = date; diff --git a/src/App.tsx b/src/App.tsx index de9c6881c..9f758f8ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,6 @@ import { Plus, Settings, ArrowLeft, - // Bot, // TODO: Agents 功能开发中,暂时不需要 Book, Wrench, RefreshCw, @@ -56,6 +55,7 @@ import { UniversalProviderPanel } from "@/components/universal"; import { McpIcon } from "@/components/BrandIcons"; import { Button } from "@/components/ui/button"; import { SessionManagerPage } from "@/components/sessions/SessionManagerPage"; +import { useDisableCurrentOmo } from "@/lib/query/omo"; type View = | "providers" @@ -68,7 +68,6 @@ type View = | "universal" | "sessions"; -// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px const HEADER_HEIGHT = 64; // px const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT; @@ -118,7 +117,6 @@ function App() { localStorage.setItem(VIEW_STORAGE_KEY, currentView); }, [currentView]); - // Get settings for visibleApps const { data: settingsData } = useSettingsQuery(); const visibleApps: VisibleApps = settingsData?.visibleApps ?? { claude: true, @@ -127,7 +125,6 @@ function App() { opencode: true, }; - // Get first visible app for fallback const getFirstVisibleApp = (): AppId => { if (visibleApps.claude) return "claude"; if (visibleApps.codex) return "codex"; @@ -136,16 +133,21 @@ function App() { return "claude"; // fallback }; - // If current active app is hidden, switch to first visible app useEffect(() => { if (!visibleApps[activeApp]) { setActiveApp(getFirstVisibleApp()); } }, [visibleApps, activeApp]); + // Fallback from sessions view when switching to an app without session support + useEffect(() => { + if (currentView === "sessions" && activeApp !== "claude" && activeApp !== "codex") { + setCurrentView("providers"); + } + }, [activeApp, currentView]); + const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); - // Confirm action state: 'remove' = remove from live config, 'delete' = delete from database const [confirmAction, setConfirmAction] = useState<{ provider: Provider; action: "remove" | "delete"; @@ -153,7 +155,6 @@ function App() { const [envConflicts, setEnvConflicts] = useState([]); const [showEnvBanner, setShowEnvBanner] = useState(false); - // 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示 const effectiveEditingProvider = useLastValidValue(editingProvider); const effectiveUsageProvider = useLastValidValue(usageProvider); @@ -164,15 +165,12 @@ function App() { const addActionButtonClass = "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; - // 获取代理服务状态 const { isRunning: isProxyRunning, takeoverStatus, status: proxyStatus, } = useProxyStatus(); - // 当前应用的代理是否开启 const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false; - // 当前应用代理实际使用的供应商 ID(从 active_targets 中获取) const activeProviderId = useMemo(() => { const target = proxyStatus?.active_targets?.find( (t) => t.app_type === activeApp, @@ -180,15 +178,14 @@ function App() { return target?.provider_id; }, [proxyStatus?.active_targets, activeApp]); - // 获取供应商列表,当代理服务运行时自动刷新 const { data, isLoading, refetch } = useProvidersQuery(activeApp, { isProxyRunning, }); const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; const hasSkillsSupport = true; + const hasSessionSupport = activeApp === "claude" || activeApp === "codex"; - // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作 const { addProvider, updateProvider, @@ -197,7 +194,23 @@ function App() { saveUsageScript, } = useProviderActions(activeApp); - // 监听来自托盘菜单的切换事件 + const disableOmoMutation = useDisableCurrentOmo(); + const handleDisableOmo = () => { + disableOmoMutation.mutate(undefined, { + onSuccess: () => { + toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" })); + }, + onError: (error: Error) => { + toast.error( + t("omo.disableFailed", { + defaultValue: "停用 OMO 失败: {{error}}", + error: extractErrorMessage(error), + }), + ); + }, + }); + }; + useEffect(() => { let unsubscribe: (() => void) | undefined; @@ -221,7 +234,6 @@ function App() { }; }, [activeApp, refetch]); - // 监听统一供应商同步事件,刷新所有应用的供应商列表 useEffect(() => { let unsubscribe: (() => void) | undefined; @@ -229,10 +241,7 @@ function App() { try { const { listen } = await import("@tauri-apps/api/event"); unsubscribe = await listen("universal-provider-synced", async () => { - // 统一供应商同步后刷新所有应用的供应商列表 - // 使用 invalidateQueries 使所有 providers 查询失效 await queryClient.invalidateQueries({ queryKey: ["providers"] }); - // 同时更新托盘菜单 try { await providersApi.updateTrayMenu(); } catch (error) { @@ -253,7 +262,6 @@ function App() { }; }, [queryClient]); - // 应用启动时检测所有应用的环境变量冲突 useEffect(() => { const checkEnvOnStartup = async () => { try { @@ -278,7 +286,6 @@ function App() { checkEnvOnStartup(); }, []); - // 应用启动时检查是否刚完成了配置迁移 useEffect(() => { const checkMigration = async () => { try { @@ -297,7 +304,6 @@ function App() { checkMigration(); }, [t]); - // 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT) useEffect(() => { const checkSkillsMigration = async () => { try { @@ -326,14 +332,12 @@ function App() { checkSkillsMigration(); }, [t, queryClient]); - // 切换应用时检测当前应用的环境变量冲突 useEffect(() => { const checkEnvOnSwitch = async () => { try { const conflicts = await checkEnvConflicts(activeApp); if (conflicts.length > 0) { - // 合并新检测到的冲突 setEnvConflicts((prev) => { const existingKeys = new Set( prev.map((c) => `${c.varName}:${c.sourcePath}`), @@ -359,7 +363,6 @@ function App() { checkEnvOnSwitch(); }, [activeApp]); - // 全局键盘快捷键 const currentViewRef = useRef(currentView); useEffect(() => { @@ -368,17 +371,14 @@ function App() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - // Cmd/Ctrl + , 打开设置 if (event.key === "," && (event.metaKey || event.ctrlKey)) { event.preventDefault(); setCurrentView("settings"); return; } - // ESC 键返回 if (event.key !== "Escape" || event.defaultPrevented) return; - // 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理 if (document.body.style.overflow === "hidden") return; const view = currentViewRef.current; @@ -396,7 +396,6 @@ function App() { }; }, []); - // 打开网站链接 const handleOpenWebsite = async (url: string) => { try { await settingsApi.openExternal(url); @@ -410,22 +409,17 @@ function App() { } }; - // 编辑供应商 const handleEditProvider = async (provider: Provider) => { await updateProvider(provider); setEditingProvider(null); }; - // 确认删除/移除供应商 const handleConfirmAction = async () => { if (!confirmAction) return; const { provider, action } = confirmAction; if (action === "remove") { - // Remove from live config only (for additive mode apps like OpenCode) - // Does NOT delete from database - provider remains in the list await providersApi.removeFromLiveConfig(provider.id, activeApp); - // Invalidate queries to refresh the isInConfig state await queryClient.invalidateQueries({ queryKey: ["opencodeLiveProviderIds"], }); @@ -436,13 +430,11 @@ function App() { { closeButton: true }, ); } else { - // Delete from database await deleteProvider(provider.id); } setConfirmAction(null); }; - // Generate a unique provider key for OpenCode duplication const generateUniqueOpencodeKey = ( originalKey: string, existingKeys: string[], @@ -453,7 +445,6 @@ function App() { return baseKey; } - // If -copy already exists, try -copy-2, -copy-3, ... let counter = 2; while (existingKeys.includes(`${baseKey}-${counter}`)) { counter++; @@ -461,9 +452,7 @@ function App() { return `${baseKey}-${counter}`; }; - // 复制供应商 const handleDuplicateProvider = async (provider: Provider) => { - // 1️⃣ 计算新的 sortIndex:如果原供应商有 sortIndex,则复制它 const newSortIndex = provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined; @@ -482,7 +471,6 @@ function App() { iconColor: provider.iconColor, }; - // OpenCode: generate unique provider key (used as ID) if (activeApp === "opencode") { const existingKeys = Object.keys(providers); duplicatedProvider.providerKey = generateUniqueOpencodeKey( @@ -491,7 +479,6 @@ function App() { ); } - // 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1 if (provider.sortIndex !== undefined) { const updates = Object.values(providers) .filter( @@ -505,7 +492,6 @@ function App() { sortIndex: p.sortIndex! + 1, })); - // 先更新现有供应商的 sortIndex,为新供应商腾出位置 if (updates.length > 0) { try { await providersApi.updateSortOrder(updates, activeApp); @@ -521,11 +507,9 @@ function App() { } } - // 3️⃣ 添加复制的供应商 await addProvider(duplicatedProvider); }; - // 打开提供商终端 const handleOpenTerminal = async (provider: Provider) => { try { await providersApi.openTerminal(provider.id, activeApp); @@ -545,10 +529,8 @@ function App() { } }; - // 导入配置成功后刷新 const handleImportSuccess = async () => { try { - // 导入会影响所有应用的供应商数据:刷新所有 providers 缓存 await queryClient.invalidateQueries({ queryKey: ["providers"], refetchType: "all", @@ -626,7 +608,6 @@ function App() { default: return (
- {/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
{ + setEditingProvider(provider); + }} onDelete={(provider) => setConfirmAction({ provider, action: "delete" }) } @@ -658,6 +641,9 @@ function App() { setConfirmAction({ provider, action: "remove" }) : undefined } + onDisableOmo={ + activeApp === "opencode" ? handleDisableOmo : undefined + } onDuplicate={handleDuplicateProvider} onConfigureUsage={setUsageProvider} onOpenWebsite={handleOpenWebsite} @@ -695,13 +681,11 @@ function App() { className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30" style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }} > - {/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
- {/* 环境变量警告横幅 */} {showEnvBanner && envConflicts.length > 0 && ( { - // 删除后重新检测 try { const allConflicts = await checkAllEnvConflicts(); const flatConflicts = Object.values(allConflicts).flat(); @@ -822,7 +805,7 @@ function App() { setSettingsDefaultTab("usage"); setCurrentView("settings"); }} - title={t("settings.usage.title", { + title={t("usage.title", { defaultValue: "使用统计", })} className="hover:bg-black/5 dark:hover:bg-white/5" @@ -970,18 +953,6 @@ function App() { > - {/* TODO: Agents 功能开发中,暂时隐藏入口 */} - {/* {isClaudeApp && ( - - )} */} - {/* 供应商图标 */}
- {/* 健康状态徽章 */} + {isOmo && ( + + OMO + + )} + {isProxyRunning && isInFailoverQueue && health && ( )} - {/* 故障转移优先级徽章 */} {isAutoFailoverEnabled && isInFailoverQueue && failoverPriority && ( @@ -318,10 +303,8 @@ export function ProviderCard({ } as React.CSSProperties } > - {/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
- {/* 多套餐时显示套餐数量,单套餐时显示详细信息 */} {hasMultiplePlans ? (
@@ -342,7 +325,6 @@ export function ProviderCard({ inline={true} /> )} - {/* 展开/折叠按钮 - 仅在有多套餐时显示 */} {hasMultiplePlans && (
- {/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
onSwitch(provider)} onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} @@ -388,10 +371,10 @@ export function ProviderCard({ ? () => onRemoveFromConfig(provider) : undefined } + onDisableOmo={onDisableOmo} onOpenTerminal={ onOpenTerminal ? () => onOpenTerminal(provider) : undefined } - // 故障转移相关 isAutoFailoverEnabled={isAutoFailoverEnabled} isInFailoverQueue={isInFailoverQueue} onToggleFailover={onToggleFailover} @@ -400,7 +383,6 @@ export function ProviderCard({
- {/* 展开的完整套餐列表 */} {isExpanded && hasMultiplePlans && (
void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; - /** OpenCode: remove from live config (not delete from database) */ onRemoveFromConfig?: (provider: Provider) => void; + onDisableOmo?: () => void; onDuplicate: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void; onOpenWebsite: (url: string) => void; @@ -61,6 +61,7 @@ export function ProviderList({ onEdit, onDelete, onRemoveFromConfig, + onDisableOmo, onDuplicate, onConfigureUsage, onOpenWebsite, @@ -77,14 +78,12 @@ export function ProviderList({ appId, ); - // OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig const { data: opencodeLiveIds } = useQuery({ queryKey: ["opencodeLiveProviderIds"], queryFn: () => providersApi.getOpenCodeLiveProviderIds(), enabled: appId === "opencode", }); - // OpenCode: 判断供应商是否已添加到 opencode.json const isProviderInConfig = useCallback( (providerId: string): boolean => { if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true @@ -93,20 +92,18 @@ export function ProviderList({ [appId, opencodeLiveIds], ); - // 流式健康检查 - 功能已隐藏 - // const { checkProvider, isChecking } = useStreamCheck(appId); - - // 故障转移相关 const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId); const { data: failoverQueue } = useFailoverQueue(appId); const addToQueue = useAddToFailoverQueue(); const removeFromQueue = useRemoveFromFailoverQueue(); - // 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式 const isFailoverModeActive = isProxyTakeover === true && isAutoFailoverEnabled === true; - // 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序) + const isOpenCode = appId === "opencode"; + const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode); + const { data: omoProviderCount } = useOmoProviderCount(isOpenCode); + const getFailoverPriority = useCallback( (providerId: string): number | undefined => { if (!isFailoverModeActive || !failoverQueue) return undefined; @@ -118,7 +115,6 @@ export function ProviderList({ [isFailoverModeActive, failoverQueue], ); - // 判断供应商是否在故障转移队列中 const isInFailoverQueue = useCallback( (providerId: string): boolean => { if (!isFailoverModeActive || !failoverQueue) return false; @@ -127,7 +123,6 @@ export function ProviderList({ [isFailoverModeActive, failoverQueue], ); - // 切换供应商的故障转移队列状态 const handleToggleFailover = useCallback( (providerId: string, enabled: boolean) => { if (enabled) { @@ -139,11 +134,6 @@ export function ProviderList({ [appId, addToQueue, removeFromQueue], ); - // handleTest 功能已隐藏 - 供应商请求格式复杂难以统一测试 - // const handleTest = (provider: Provider) => { - // checkProvider(provider.id, provider.name); - // }; - const [searchTerm, setSearchTerm] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); @@ -215,36 +205,44 @@ export function ProviderList({ strategy={verticalListSortingStrategy} >
- {filteredProviders.map((provider) => ( - - handleToggleFailover(provider.id, enabled) - } - activeProviderId={activeProviderId} - /> - ))} + {filteredProviders.map((provider) => { + const isOmo = provider.category === "omo"; + const isOmoCurrent = isOmo && provider.id === (currentOmoId || ""); + return ( + + handleToggleFailover(provider.id, enabled) + } + activeProviderId={activeProviderId} + /> + ); + })}
@@ -334,11 +332,13 @@ interface SortableProviderCardProps { isCurrent: boolean; appId: AppId; isInConfig: boolean; + isOmo: boolean; + isLastOmo: boolean; onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; - /** OpenCode: remove from live config (not delete from database) */ onRemoveFromConfig?: (provider: Provider) => void; + onDisableOmo?: () => void; onDuplicate: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void; onOpenWebsite: (url: string) => void; @@ -347,7 +347,6 @@ interface SortableProviderCardProps { isTesting: boolean; isProxyRunning: boolean; isProxyTakeover: boolean; - // 故障转移相关 isAutoFailoverEnabled: boolean; failoverPriority?: number; isInFailoverQueue: boolean; @@ -360,10 +359,13 @@ function SortableProviderCard({ isCurrent, appId, isInConfig, + isOmo, + isLastOmo, onSwitch, onEdit, onDelete, onRemoveFromConfig, + onDisableOmo, onDuplicate, onConfigureUsage, onOpenWebsite, @@ -399,10 +401,13 @@ function SortableProviderCard({ isCurrent={isCurrent} appId={appId} isInConfig={isInConfig} + isOmo={isOmo} + isLastOmo={isLastOmo} onSwitch={onSwitch} onEdit={onEdit} onDelete={onDelete} onRemoveFromConfig={onRemoveFromConfig} + onDisableOmo={onDisableOmo} onDuplicate={onDuplicate} onConfigureUsage={ onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined @@ -418,7 +423,6 @@ function SortableProviderCard({ listeners, isDragging, }} - // 故障转移相关 isAutoFailoverEnabled={isAutoFailoverEnabled} failoverPriority={failoverPriority} isInFailoverQueue={isInFailoverQueue} diff --git a/src/components/providers/forms/OmoCommonConfigEditor.tsx b/src/components/providers/forms/OmoCommonConfigEditor.tsx new file mode 100644 index 000000000..575d3c54b --- /dev/null +++ b/src/components/providers/forms/OmoCommonConfigEditor.tsx @@ -0,0 +1,161 @@ +import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Save, FolderInput, Loader2 } from "lucide-react"; +import JsonEditor from "@/components/JsonEditor"; +import { + OmoGlobalConfigFields, + type OmoGlobalConfigFieldsRef, +} from "./OmoGlobalConfigFields"; +import type { OmoGlobalConfig } from "@/types/omo"; + +interface OmoCommonConfigEditorProps { + previewValue: string; + useCommonConfig: boolean; + onCommonConfigToggle: (checked: boolean) => void; + isModalOpen: boolean; + onEditClick: () => void; + onModalClose: () => void; + onSave: () => Promise; + isSaving: boolean; + onGlobalConfigStateChange: (config: OmoGlobalConfig) => void; + globalConfigRef: React.RefObject; + fieldsKey: number; +} + +export function OmoCommonConfigEditor({ + previewValue, + useCommonConfig, + onCommonConfigToggle, + isModalOpen, + onEditClick, + onModalClose, + onSave, + isSaving, + onGlobalConfigStateChange, + globalConfigRef, + fieldsKey, +}: OmoCommonConfigEditorProps) { + const { t } = useTranslation(); + const [isDarkMode, setIsDarkMode] = useState(false); + const [isImporting, setIsImporting] = useState(false); + useEffect(() => { + const syncDarkMode = () => + setIsDarkMode(document.documentElement.classList.contains("dark")); + syncDarkMode(); + const observer = new MutationObserver(syncDarkMode); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + const handleImportLocal = async () => { + if (!globalConfigRef.current) return; + setIsImporting(true); + try { + await globalConfigRef.current.importFromLocal(); + } finally { + setIsImporting(false); + } + }; + return ( + <> +
+
+ +
+ +
+
+
+ +
+ {}} + darkMode={isDarkMode} + rows={14} + showValidation={false} + language="json" + /> +
+ + + + + + } + > +
+

+ {t("omo.commonConfigHint", { + defaultValue: + "OMO common config will be merged into all OMO configs that enable it", + })} +

+ } + onStateChange={onGlobalConfigStateChange} + hideSaveButtons + /> +
+
+ + ); +} diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx new file mode 100644 index 000000000..2ee356a3c --- /dev/null +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -0,0 +1,1031 @@ +import { useState, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Plus, + Trash2, + ChevronDown, + ChevronRight, + Wand2, + Settings, + FolderInput, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { useReadOmoLocalFile } from "@/lib/query/omo"; +import { + OMO_BUILTIN_AGENTS, + OMO_BUILTIN_CATEGORIES, + type OmoAgentDef, + type OmoCategoryDef, +} from "@/types/omo"; + +const ADVANCED_PLACEHOLDER = `{ + "temperature": 0.5, + "top_p": 0.9, + "budgetTokens": 20000, + "prompt_append": "", + "permission": { "edit": "allow", "bash": "ask" } +}`; + +interface OmoFormFieldsProps { + modelOptions: Array<{ value: string; label: string }>; + modelVariantsMap?: Record; + agents: Record>; + onAgentsChange: (agents: Record>) => void; + categories: Record>; + onCategoriesChange: ( + categories: Record>, + ) => void; + otherFieldsStr: string; + onOtherFieldsStrChange: (value: string) => void; +} + +type CustomModelItem = { key: string; model: string }; +type BuiltinModelDef = Pick< + OmoAgentDef | OmoCategoryDef, + "key" | "display" | "descZh" | "descEn" | "recommended" +>; +type ModelOption = { value: string; label: string }; + +const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key)); +const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key)); +const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__"; +const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__"; +const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__"; +const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__"; + +function getAdvancedStr(config: Record | undefined): string { + if (!config) return ""; + const adv: Record = {}; + for (const [k, v] of Object.entries(config)) { + if (k !== "model" && k !== "variant") adv[k] = v; + } + return Object.keys(adv).length > 0 ? JSON.stringify(adv, null, 2) : ""; +} + +function collectCustomModels( + store: Record>, + builtinKeys: Set, +): CustomModelItem[] { + const customs: CustomModelItem[] = []; + for (const [k, v] of Object.entries(store)) { + if (!builtinKeys.has(k) && typeof v === "object" && v !== null) { + customs.push({ + key: k, + model: ((v as Record).model as string) || "", + }); + } + } + return customs; +} + +function mergeCustomModelsIntoStore( + store: Record>, + builtinKeys: Set, + customs: CustomModelItem[], +): Record> { + const updated = { ...store }; + for (const key of Object.keys(updated)) { + if (!builtinKeys.has(key)) delete updated[key]; + } + for (const custom of customs) { + if (custom.key.trim()) { + updated[custom.key] = { ...updated[custom.key], model: custom.model }; + } + } + return updated; +} + +export function OmoFormFields({ + modelOptions, + modelVariantsMap = {}, + agents, + onAgentsChange, + categories, + onCategoriesChange, + otherFieldsStr, + onOtherFieldsStrChange, +}: OmoFormFieldsProps) { + const { t, i18n } = useTranslation(); + const isZh = i18n.language?.startsWith("zh"); + + const [mainAgentsOpen, setMainAgentsOpen] = useState(true); + const [subAgentsOpen, setSubAgentsOpen] = useState(true); + const [categoriesOpen, setCategoriesOpen] = useState(true); + const [otherFieldsOpen, setOtherFieldsOpen] = useState(false); + + const [expandedAgents, setExpandedAgents] = useState>( + {}, + ); + const [expandedCategories, setExpandedCategories] = useState< + Record + >({}); + const [agentAdvancedDrafts, setAgentAdvancedDrafts] = useState< + Record + >({}); + const [categoryAdvancedDrafts, setCategoryAdvancedDrafts] = useState< + Record + >({}); + + const [customAgents, setCustomAgents] = useState(() => + collectCustomModels(agents, BUILTIN_AGENT_KEYS), + ); + + const [customCategories, setCustomCategories] = useState( + () => collectCustomModels(categories, BUILTIN_CATEGORY_KEYS), + ); + + useEffect(() => { + setCustomAgents(collectCustomModels(agents, BUILTIN_AGENT_KEYS)); + }, [agents]); + + useEffect(() => { + setCustomCategories(collectCustomModels(categories, BUILTIN_CATEGORY_KEYS)); + }, [categories]); + + const syncCustomAgents = useCallback( + (customs: CustomModelItem[]) => { + onAgentsChange( + mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs), + ); + }, + [agents, onAgentsChange], + ); + + const syncCustomCategories = useCallback( + (customs: CustomModelItem[]) => { + onCategoriesChange( + mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs), + ); + }, + [categories, onCategoriesChange], + ); + + const buildEffectiveModelOptions = useCallback( + (currentModel: string): ModelOption[] => { + if (!currentModel) return modelOptions; + if (modelOptions.some((item) => item.value === currentModel)) { + return modelOptions; + } + return [ + { + value: currentModel, + label: t("omo.currentValueNotEnabled", { + value: currentModel, + defaultValue: "{{value}} (current value, not enabled)", + }), + }, + ...modelOptions, + ]; + }, + [modelOptions, t], + ); + + const resolveRecommendedModel = useCallback( + (recommended?: string): string | undefined => { + if (!recommended || modelOptions.length === 0) return undefined; + + const exact = modelOptions.find((item) => item.value === recommended); + if (exact) return exact.value; + + const bySuffix = modelOptions.find((item) => + item.value.endsWith(`/${recommended}`), + ); + return bySuffix?.value; + }, + [modelOptions], + ); + + const renderModelSelect = ( + currentModel: string, + onChange: (value: string) => void, + placeholder?: string, + ) => { + const options = buildEffectiveModelOptions(currentModel); + return ( + + ); + }; + + const buildEffectiveVariantOptions = useCallback( + (currentModel: string, currentVariant: string): string[] => { + const variantKeys = modelVariantsMap[currentModel] || []; + if (!currentVariant || variantKeys.includes(currentVariant)) { + return variantKeys; + } + return [currentVariant, ...variantKeys]; + }, + [modelVariantsMap], + ); + + const renderVariantSelect = ( + currentModel: string, + currentVariant: string, + onChange: (value: string) => void, + ) => { + const variantOptions = buildEffectiveVariantOptions( + currentModel, + currentVariant, + ); + const hasModel = Boolean(currentModel); + const firstIsUnavailable = + Boolean(currentVariant) && + !(modelVariantsMap[currentModel] || []).includes(currentVariant); + + return ( + + ); + }; + + const handleModelChange = ( + key: string, + model: string, + store: Record>, + setter: (v: Record>) => void, + ) => { + if (model.trim()) { + const nextEntry: Record = { + ...(store[key] || {}), + model, + }; + const currentVariant = + typeof nextEntry.variant === "string" ? nextEntry.variant : ""; + if (currentVariant) { + const validVariants = modelVariantsMap[model] || []; + if (!validVariants.includes(currentVariant)) { + delete nextEntry.variant; + } + } + setter({ ...store, [key]: nextEntry }); + } else { + const existing = store[key]; + if (existing) { + const adv = { ...existing }; + delete adv.model; + delete adv.variant; + if (Object.keys(adv).length > 0) { + setter({ ...store, [key]: adv }); + } else { + const next = { ...store }; + delete next[key]; + setter(next); + } + } + } + }; + + const handleVariantChange = ( + key: string, + variant: string, + store: Record>, + setter: (v: Record>) => void, + ) => { + const existing = store[key]; + if (variant.trim()) { + setter({ ...store, [key]: { ...existing, variant } }); + return; + } + + if (!existing) return; + const nextEntry = { ...existing }; + delete nextEntry.variant; + if (Object.keys(nextEntry).length > 0) { + setter({ ...store, [key]: nextEntry }); + return; + } + + const next = { ...store }; + delete next[key]; + setter(next); + }; + + const handleAdvancedChange = ( + key: string, + rawJson: string, + store: Record>, + setter: (v: Record>) => void, + ): boolean => { + const currentModel = (store[key]?.model as string) || ""; + const currentVariant = (store[key]?.variant as string) || ""; + if (!rawJson.trim()) { + if (currentModel || currentVariant) { + setter({ + ...store, + [key]: { + ...(currentModel ? { model: currentModel } : {}), + ...(currentVariant ? { variant: currentVariant } : {}), + }, + }); + } else { + const next = { ...store }; + delete next[key]; + setter(next); + } + return true; + } + try { + const parsed = JSON.parse(rawJson); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + const parsedAdvanced = { ...(parsed as Record) }; + delete parsedAdvanced.model; + delete parsedAdvanced.variant; + setter({ + ...store, + [key]: { + ...(currentModel ? { model: currentModel } : {}), + ...(currentVariant ? { variant: currentVariant } : {}), + ...parsedAdvanced, + }, + }); + return true; + } + return false; + } catch { + return false; + } + }; + + type AdvancedScope = "agent" | "category"; + + const setAdvancedDraft = ( + scope: AdvancedScope, + key: string, + value: string, + ) => { + if (scope === "agent") { + setAgentAdvancedDrafts((prev) => ({ ...prev, [key]: value })); + return; + } + setCategoryAdvancedDrafts((prev) => ({ ...prev, [key]: value })); + }; + + const removeAdvancedDraft = (scope: AdvancedScope, key: string) => { + if (scope === "agent") { + setAgentAdvancedDrafts((prev) => { + const copied = { ...prev }; + delete copied[key]; + return copied; + }); + return; + } + setCategoryAdvancedDrafts((prev) => { + const copied = { ...prev }; + delete copied[key]; + return copied; + }); + }; + + const toggleAdvancedEditor = ( + scope: AdvancedScope, + key: string, + advStr: string, + isExpanded: boolean, + ) => { + const willOpen = !isExpanded; + if (scope === "agent") { + setExpandedAgents((prev) => ({ ...prev, [key]: willOpen })); + if (willOpen && agentAdvancedDrafts[key] === undefined) { + setAdvancedDraft(scope, key, advStr); + } + return; + } + setExpandedCategories((prev) => ({ ...prev, [key]: willOpen })); + if (willOpen && categoryAdvancedDrafts[key] === undefined) { + setAdvancedDraft(scope, key, advStr); + } + }; + + const renderAdvancedEditor = ({ + scope, + draftKey, + configKey, + draftValue, + store, + setter, + showHint, + }: { + scope: AdvancedScope; + draftKey: string; + configKey: string; + draftValue: string; + store: Record>; + setter: (value: Record>) => void; + showHint?: boolean; + }) => ( +
+
PackyCode感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用此链接注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠!感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用此链接注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!
RightCode感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打极高性价比的Codex包月套餐,提供额度转结,套餐当天用不完的额度,第二天还能接着用!充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过此链接注册,每次充值均可获得实付金额25%的按量额度!
AICoding 感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过此链接 注册的 CC Switch 用户,首充可享受九折优惠!