mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(grokbuild): add first-class Grok Build support (#5453)
* feat(grokbuild): add backend integration * feat(grokbuild): add provider and management UI * test(grokbuild): cover configuration and integrations * fix(grokbuild): address backend review feedback * fix(grokbuild): complete UI review feedback * feat(grokbuild): add CLI lifecycle management * fix(grokbuild): align provider icon fallback * test(grokbuild): cover provider icon persistence
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -195,13 +195,13 @@ TeamoRouter also offers enterprise features including centralized billing, team
|
||||
|
||||
## Why CC Switch?
|
||||
|
||||
Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
|
||||
Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, and Hermes — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
|
||||
|
||||
**CC Switch** gives you a single desktop app to manage all supported AI tools. Instead of editing config files by hand, you get a visual interface to import providers with one click, switch between them instantly, with 50+ built-in provider presets, unified MCP and Skills management, and system tray quick switching — all backed by a reliable SQLite database with atomic writes that protect your configs from corruption.
|
||||
|
||||
- **One App, Seven Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes from a single interface
|
||||
- **One App, Eight Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, and Hermes from a single interface
|
||||
- **No More Manual Editing** — 50+ provider presets including AWS Bedrock, NVIDIA NIM, and community relays; just pick and switch
|
||||
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync
|
||||
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, Grok Build, OpenCode, and Hermes with bidirectional sync
|
||||
- **System Tray Quick Switch** — Switch providers instantly from the tray menu, no need to open the full app
|
||||
- **Cloud Sync** — Sync provider data across devices via Dropbox, OneDrive, iCloud, or WebDAV servers
|
||||
- **Cross-Platform** — Native desktop app for Windows, macOS, and Linux, built with Tauri 2
|
||||
@@ -219,18 +219,18 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
|
||||
|
||||
### Provider Management
|
||||
|
||||
- **7 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; copy your key and import with one click
|
||||
- **8 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes; copy your key and import with one click
|
||||
- **Universal providers** — One config syncs to Claude Code, Codex, and Gemini CLI
|
||||
- One-click switching, system tray quick access, drag-and-drop sorting, import/export
|
||||
|
||||
### Proxy & Failover
|
||||
|
||||
- **Local proxy with hot-switching** — Format conversion, auto-failover, circuit breaker, provider health monitoring, and request rectifier
|
||||
- **App-level takeover** — Independently proxy Claude, Codex, or Gemini, down to individual providers
|
||||
- **App-level takeover** — Independently proxy Claude, Codex, Gemini, or Grok Build, down to individual providers
|
||||
|
||||
### MCP, Prompts & Skills
|
||||
|
||||
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync and Deep Link import
|
||||
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, Grok Build, OpenCode, and Hermes with bidirectional sync and Deep Link import
|
||||
- **Prompts** — Markdown editor with cross-app sync (CLAUDE.md / AGENTS.md / GEMINI.md) and backfill protection
|
||||
- **Skills** — One-click install from GitHub repos or ZIP files, custom repository management, with symlink and file copy support
|
||||
|
||||
@@ -254,7 +254,7 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
|
||||
<details>
|
||||
<summary><strong>Which AI tools does CC Switch support?</strong></summary>
|
||||
|
||||
CC Switch supports seven tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
|
||||
CC Switch supports eight tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **Grok Build**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+8
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -195,13 +195,13 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
|
||||
|
||||
## Warum CC Switch?
|
||||
|
||||
Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
|
||||
Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw und Hermes — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
|
||||
|
||||
**CC Switch** gibt Ihnen eine einzige Desktop-App, um alle unterstützten KI-Werkzeuge zu verwalten. Statt Konfigurationsdateien von Hand zu bearbeiten, erhalten Sie eine visuelle Oberfläche, um Anbieter mit einem Klick zu importieren und sofort zwischen ihnen zu wechseln — mit 50+ integrierten Anbieter-Presets, einheitlicher MCP- und Skills-Verwaltung und schnellem Umschalten über das System-Tray. Das Ganze gestützt auf eine zuverlässige SQLite-Datenbank mit atomaren Schreibvorgängen, die Ihre Konfigurationen vor Beschädigung schützen.
|
||||
|
||||
- **Eine App, sieben Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
|
||||
- **Eine App, acht Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
|
||||
- **Kein manuelles Bearbeiten mehr** — 50+ Anbieter-Presets einschließlich AWS Bedrock, NVIDIA NIM und Community-Relays; einfach auswählen und umschalten
|
||||
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung
|
||||
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung
|
||||
- **Schnellumschaltung über System-Tray** — Wechseln Sie Anbieter sofort über das Tray-Menü, ohne die vollständige App öffnen zu müssen
|
||||
- **Cloud-Synchronisierung** — Synchronisieren Sie Anbieterdaten geräteübergreifend über Dropbox, OneDrive, iCloud oder WebDAV-Server
|
||||
- **Plattformübergreifend** — Native Desktop-App für Windows, macOS und Linux, gebaut mit Tauri 2
|
||||
@@ -219,18 +219,18 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
|
||||
|
||||
### Anbieterverwaltung
|
||||
|
||||
- **7 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; Schlüssel kopieren und mit einem Klick importieren
|
||||
- **8 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes; Schlüssel kopieren und mit einem Klick importieren
|
||||
- **Universelle Anbieter** — Eine Konfiguration synchronisiert sich mit Claude Code, Codex und Gemini CLI
|
||||
- Umschaltung mit einem Klick, Schnellzugriff über System-Tray, Sortierung per Drag-and-drop, Import/Export
|
||||
|
||||
### Proxy & Failover
|
||||
|
||||
- **Lokaler Proxy mit Hot-Switching** — Formatkonvertierung, automatisches Failover, Circuit Breaker, Anbieter-Health-Monitoring und Request-Rectifier
|
||||
- **Übernahme auf App-Ebene** — Claude, Codex oder Gemini unabhängig über den Proxy leiten, bis hinunter auf einzelne Anbieter
|
||||
- **Übernahme auf App-Ebene** — Claude, Codex, Gemini oder Grok Build unabhängig über den Proxy leiten, bis hinunter auf einzelne Anbieter
|
||||
|
||||
### MCP, Prompts & Skills
|
||||
|
||||
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
|
||||
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
|
||||
- **Prompts** — Markdown-Editor mit App-übergreifender Synchronisierung (CLAUDE.md / AGENTS.md / GEMINI.md) und Backfill-Schutz
|
||||
- **Skills** — Installation mit einem Klick aus GitHub-Repositorys oder ZIP-Dateien, Verwaltung eigener Repositorys, mit Unterstützung für Symlinks und Dateikopien
|
||||
|
||||
@@ -254,7 +254,7 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
|
||||
<details>
|
||||
<summary><strong>Welche KI-Werkzeuge unterstützt CC Switch?</strong></summary>
|
||||
|
||||
CC Switch unterstützt sieben Werkzeuge: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw** und **Hermes**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
|
||||
CC Switch unterstützt acht Werkzeuge: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **Grok Build**, **OpenCode**, **OpenClaw** und **Hermes**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+8
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -195,13 +195,13 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
|
||||
## CC Switch を選ぶ理由
|
||||
|
||||
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
|
||||
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
|
||||
|
||||
**CC Switch** は、対応する AI ツールを 1 つのデスクトップアプリで一元管理できます。設定ファイルを手作業で編集する代わりに、ワンクリックでプロバイダをインポートし、瞬時に切り替えられるビジュアルインターフェースを提供します。50 以上の組み込みプリセット、統一 MCP・Skills 管理、システムトレイからの即時切り替え機能を搭載。すべてはアトミック書き込みによる信頼性の高い SQLite データベースに支えられており、設定の破損を防ぎます。
|
||||
|
||||
- **1 つのアプリで 7 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
|
||||
- **1 つのアプリで 8 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
|
||||
- **手動編集は不要** -- AWS Bedrock、NVIDIA NIM、コミュニティリレーなど 50 以上のプロバイダプリセットを内蔵。選んで切り替えるだけ
|
||||
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
|
||||
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
|
||||
- **システムトレイでクイック切り替え** -- トレイメニューから即座にプロバイダを切り替え。アプリを開く必要なし
|
||||
- **クラウド同期** -- Dropbox、OneDrive、iCloud、または WebDAV サーバー経由でデバイス間のプロバイダデータを同期
|
||||
- **クロスプラットフォーム** -- Tauri 2 で構築された Windows、macOS、Linux 対応のネイティブデスクトップアプリ
|
||||
@@ -219,18 +219,18 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
|
||||
### プロバイダ管理
|
||||
|
||||
- **7 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
|
||||
- **8 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
|
||||
- **ユニバーサルプロバイダ** -- 1 つの設定を Claude Code、Codex、Gemini CLI に同期
|
||||
- ワンクリック切り替え、システムトレイクイックアクセス、ドラッグ&ドロップ並び替え、インポート/エクスポート
|
||||
|
||||
### プロキシ & フェイルオーバー
|
||||
|
||||
- **ローカルプロキシのホットスイッチ** -- フォーマット変換、自動フェイルオーバー、サーキットブレーカー、プロバイダヘルスモニタリング、リクエストレクティファイア
|
||||
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
|
||||
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini、Grok Build を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
|
||||
|
||||
### MCP、Prompts & Skills
|
||||
|
||||
- **統一 MCP パネル** -- Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
|
||||
- **統一 MCP パネル** -- Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
|
||||
- **Prompts** -- Markdown エディタ、クロスアプリ同期(CLAUDE.md / AGENTS.md / GEMINI.md)、バックフィル保護
|
||||
- **Skills** -- GitHub リポジトリまたは ZIP ファイルからワンクリックインストール、カスタムリポジトリ管理、シンボリックリンクとファイルコピーに対応
|
||||
|
||||
@@ -254,7 +254,7 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
<details>
|
||||
<summary><strong>CC Switch はどの AI ツールに対応していますか?</strong></summary>
|
||||
|
||||
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw**、**Hermes** の 7 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
|
||||
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw**、**Hermes** の 8 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+8
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -196,13 +196,13 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
|
||||
## 为什么选择 CC Switch?
|
||||
|
||||
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
|
||||
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
|
||||
|
||||
**CC Switch** 为你提供一个桌面应用来管理所有支持的 AI 工具。无需手动编辑配置文件,你将获得一个可视化界面,一键将供应商导入应用,一键在不同的供应商之间进行切换,内置 50+ 供应商预设、统一的 MCP, SKILLS 管理以及系统托盘即时切换功能——所有操作都基于可靠的 SQLite 数据库和原子写入机制,保护你的配置不被损坏。
|
||||
|
||||
- **一个应用,七个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes
|
||||
- **一个应用,八个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes
|
||||
- **告别手动编辑** — 50+ 供应商预设,包括 AWS Bedrock、NVIDIA NIM 和社区中转服务;一键即可切换
|
||||
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
|
||||
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
|
||||
- **系统托盘快速切换** — 从托盘菜单即时切换供应商,无需打开完整应用
|
||||
- **云同步** — 通过 Dropbox、OneDrive、iCloud 或 WebDAV 服务器在不同设备之间同步供应商数据
|
||||
- **跨平台** — 基于 Tauri 2 构建的原生桌面应用,支持 Windows、macOS 和 Linux
|
||||
@@ -220,18 +220,18 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
|
||||
### 供应商管理
|
||||
|
||||
- **7 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
|
||||
- **8 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
|
||||
- **通用供应商** — 一份配置同步到 Claude Code、Codex 和 Gemini CLI
|
||||
- 一键切换、系统托盘快速访问、拖拽排序、导入导出
|
||||
|
||||
### 代理与故障转移
|
||||
|
||||
- **本地代理热切换** — 格式转换、自动故障转移、熔断器、供应商健康监控和整流器
|
||||
- **应用级代理接管** — 独立为 Claude、Codex 或 Gemini 配置代理,具体到单个供应商
|
||||
- **应用级代理接管** — 独立为 Claude、Codex、Gemini 或 Grok Build 配置代理,具体到单个供应商
|
||||
|
||||
### MCP、Prompts 与 Skills
|
||||
|
||||
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
|
||||
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
|
||||
- **Prompts** — Markdown 编辑器,跨应用同步(CLAUDE.md / AGENTS.md / GEMINI.md),回填保护
|
||||
- **Skills** — 从 GitHub 仓库或 ZIP 文件一键安装,自定义仓库管理,支持软连接和文件复制
|
||||
|
||||
@@ -255,7 +255,7 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
<details>
|
||||
<summary><strong>CC Switch 支持哪些 AI 工具?</strong></summary>
|
||||
|
||||
CC Switch 支持七个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
|
||||
CC Switch 支持八个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct McpApps {
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
@@ -26,6 +28,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::GrokBuild => self.grokbuild,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
AppType::Hermes => self.hermes,
|
||||
@@ -39,6 +42,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::GrokBuild => self.grokbuild = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
@@ -58,6 +62,9 @@ impl McpApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.grokbuild {
|
||||
apps.push(AppType::GrokBuild);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
@@ -69,7 +76,12 @@ impl McpApps {
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
!self.claude
|
||||
&& !self.codex
|
||||
&& !self.gemini
|
||||
&& !self.grokbuild
|
||||
&& !self.opencode
|
||||
&& !self.hermes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +95,8 @@ pub struct SkillApps {
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
@@ -95,6 +109,7 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::GrokBuild => self.grokbuild,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
@@ -108,6 +123,7 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::GrokBuild => self.grokbuild = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
@@ -127,6 +143,9 @@ impl SkillApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.grokbuild {
|
||||
apps.push(AppType::GrokBuild);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
@@ -138,7 +157,12 @@ impl SkillApps {
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
!self.claude
|
||||
&& !self.codex
|
||||
&& !self.gemini
|
||||
&& !self.grokbuild
|
||||
&& !self.opencode
|
||||
&& !self.hermes
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
@@ -271,6 +295,8 @@ pub struct McpRoot {
|
||||
pub codex: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub gemini: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub grokbuild: McpConfig,
|
||||
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub opencode: McpConfig,
|
||||
@@ -292,6 +318,7 @@ impl Default for McpRoot {
|
||||
claude_desktop: McpConfig::default(),
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
grokbuild: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
openclaw: McpConfig::default(),
|
||||
hermes: McpConfig::default(),
|
||||
@@ -323,6 +350,8 @@ pub struct PromptRoot {
|
||||
#[serde(default)]
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub grokbuild: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub openclaw: PromptConfig,
|
||||
@@ -348,6 +377,7 @@ pub enum AppType {
|
||||
ClaudeDesktop,
|
||||
Codex,
|
||||
Gemini,
|
||||
GrokBuild,
|
||||
OpenCode,
|
||||
OpenClaw,
|
||||
Hermes,
|
||||
@@ -360,6 +390,7 @@ impl AppType {
|
||||
AppType::ClaudeDesktop => "claude-desktop",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::GrokBuild => "grokbuild",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
AppType::Hermes => "hermes",
|
||||
@@ -384,6 +415,7 @@ impl AppType {
|
||||
AppType::ClaudeDesktop,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::GrokBuild,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
@@ -402,13 +434,14 @@ impl FromStr for AppType {
|
||||
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"grokbuild" | "grok-build" | "grok_build" | "grok" => Ok(AppType::GrokBuild),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"openclaw" => Ok(AppType::OpenClaw),
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -444,6 +477,7 @@ impl CommonConfigSnippets {
|
||||
AppType::ClaudeDesktop => None,
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::GrokBuild => None,
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
||||
AppType::Hermes => self.hermes.as_ref(),
|
||||
@@ -457,6 +491,7 @@ impl CommonConfigSnippets {
|
||||
AppType::ClaudeDesktop => {}
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::GrokBuild => {}
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
AppType::OpenClaw => self.openclaw = snippet,
|
||||
AppType::Hermes => self.hermes = snippet,
|
||||
@@ -500,6 +535,7 @@ impl Default for MultiAppConfig {
|
||||
apps.insert("claude-desktop".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("grokbuild".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
apps.insert("openclaw".to_string(), ProviderManager::default());
|
||||
apps.insert("hermes".to_string(), ProviderManager::default());
|
||||
@@ -662,6 +698,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::GrokBuild => &self.mcp.grokbuild,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
AppType::OpenClaw => &self.mcp.openclaw,
|
||||
AppType::Hermes => &self.mcp.hermes,
|
||||
@@ -675,6 +712,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::GrokBuild => &mut self.mcp.grokbuild,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
AppType::OpenClaw => &mut self.mcp.openclaw,
|
||||
AppType::Hermes => &mut self.mcp.hermes,
|
||||
@@ -691,6 +729,7 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::GrokBuild)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
|
||||
@@ -714,6 +753,7 @@ impl MultiAppConfig {
|
||||
|| !self.prompts.claude_desktop.prompts.is_empty()
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.grokbuild.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
|| !self.prompts.openclaw.prompts.is_empty()
|
||||
|| !self.prompts.hermes.prompts.is_empty()
|
||||
@@ -728,6 +768,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::GrokBuild,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
@@ -801,6 +842,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::GrokBuild => &mut config.prompts.grokbuild.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
|
||||
AppType::Hermes => &mut config.prompts.hermes.prompts,
|
||||
@@ -843,6 +885,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::GrokBuild => continue,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
|
||||
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
|
||||
@@ -1133,6 +1176,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn auto_imports_grokbuild_prompt_on_first_launch() {
|
||||
let _home = TempHome::new();
|
||||
write_prompt_file(AppType::GrokBuild, "# Grok Build Prompt\n\nTest content");
|
||||
|
||||
let config = MultiAppConfig::load().expect("load config");
|
||||
|
||||
assert_eq!(config.prompts.grokbuild.prompts.len(), 1);
|
||||
let prompt = config
|
||||
.prompts
|
||||
.grokbuild
|
||||
.prompts
|
||||
.values()
|
||||
.next()
|
||||
.expect("grokbuild prompt exists");
|
||||
assert!(prompt.enabled, "grokbuild prompt should be enabled");
|
||||
assert_eq!(prompt.content, "# Grok Build Prompt\n\nTest content");
|
||||
assert_eq!(
|
||||
prompt.description,
|
||||
Some("Automatically imported on first launch".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn auto_imports_all_three_apps_prompts() {
|
||||
|
||||
@@ -99,6 +99,15 @@ pub async fn get_config_status(
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let config_path = crate::grok_config::get_grok_config_path();
|
||||
let exists = config_path.exists();
|
||||
let path = crate::grok_config::get_grok_config_dir()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
let config_path = crate::opencode_config::get_opencode_config_path();
|
||||
let exists = config_path.exists();
|
||||
@@ -143,6 +152,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
@@ -160,6 +170,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
|
||||
@@ -111,8 +111,8 @@ pub struct ToolVersion {
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
const VALID_TOOLS: [&str; 6] = [
|
||||
"claude", "codex", "gemini", "opencode", "openclaw", "hermes",
|
||||
const VALID_TOOLS: [&str; 7] = [
|
||||
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
@@ -424,6 +424,7 @@ fn tool_display_name(tool: &str) -> &'static str {
|
||||
"claude" => "Claude Code",
|
||||
"codex" => "Codex",
|
||||
"gemini" => "Gemini CLI",
|
||||
"grok" => "Grok Build",
|
||||
"opencode" => "OpenCode",
|
||||
"openclaw" => "OpenClaw",
|
||||
"hermes" => "Hermes",
|
||||
@@ -492,6 +493,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
|
||||
"claude" => Some("npm i -g @anthropic-ai/claude-code@latest"),
|
||||
"codex" => Some("npm i -g @openai/codex@latest"),
|
||||
"gemini" => Some("npm i -g @google/gemini-cli@latest"),
|
||||
"grok" => Some("npm i -g @xai-official/grok@latest"),
|
||||
"opencode" => Some("npm i -g opencode-ai@latest"),
|
||||
"openclaw" => Some("npm i -g openclaw@latest"),
|
||||
_ => None,
|
||||
@@ -762,6 +764,7 @@ async fn get_single_tool_version_impl(
|
||||
}
|
||||
"codex" => fetch_npm_latest_for_tool(&client, "@openai/codex", tool, local).await,
|
||||
"gemini" => fetch_npm_latest_for_tool(&client, "@google/gemini-cli", tool, local).await,
|
||||
"grok" => fetch_npm_latest_for_tool(&client, "@xai-official/grok", tool, local).await,
|
||||
"opencode" => {
|
||||
if let Some(version) =
|
||||
fetch_npm_latest_for_tool(&client, "opencode-ai", tool, local).await
|
||||
@@ -1945,6 +1948,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> {
|
||||
"claude" => Some("@anthropic-ai/claude-code"),
|
||||
"codex" => Some("@openai/codex"),
|
||||
"gemini" => Some("@google/gemini-cli"),
|
||||
"grok" => Some("@xai-official/grok"),
|
||||
"opencode" => Some("opencode-ai"),
|
||||
"openclaw" => Some("openclaw"),
|
||||
_ => None,
|
||||
@@ -2257,7 +2261,8 @@ fn package_manager_anchored_command_from_paths(
|
||||
/// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。
|
||||
/// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>`;
|
||||
/// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。
|
||||
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 bin 目录的 npm"。
|
||||
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI / Grok Build) → 锚定到
|
||||
/// "那处 bin 目录的 npm"。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
|
||||
let real_lower = real_target.to_ascii_lowercase();
|
||||
@@ -2553,6 +2558,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
"claude" => crate::settings::get_claude_override_dir(),
|
||||
"codex" => crate::settings::get_codex_override_dir(),
|
||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||
"grok" => crate::settings::get_grok_override_dir(),
|
||||
"opencode" => crate::settings::get_opencode_override_dir(),
|
||||
"openclaw" => crate::settings::get_openclaw_override_dir(),
|
||||
"hermes" => crate::settings::get_hermes_override_dir(),
|
||||
@@ -3660,6 +3666,35 @@ mod tests {
|
||||
assert_eq!(extract_version("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_lifecycle_metadata_is_consistent() {
|
||||
let requested = vec!["unsupported".to_string(), "grok".to_string()];
|
||||
assert_eq!(normalize_requested_tools(&requested), vec!["grok"]);
|
||||
assert_eq!(tool_display_name("grok"), "Grok Build");
|
||||
assert_eq!(npm_package_for("grok"), Some("@xai-official/grok"));
|
||||
assert_eq!(
|
||||
npm_install_command_for("grok"),
|
||||
Some("npm i -g @xai-official/grok@latest")
|
||||
);
|
||||
assert_eq!(official_update_args("grok"), None);
|
||||
|
||||
for shell in [
|
||||
LifecycleCommandShell::Posix,
|
||||
LifecycleCommandShell::WindowsBatch,
|
||||
] {
|
||||
assert_eq!(
|
||||
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Install, shell,)
|
||||
.as_deref(),
|
||||
Some("npm i -g @xai-official/grok@latest")
|
||||
);
|
||||
assert_eq!(
|
||||
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Update, shell,)
|
||||
.as_deref(),
|
||||
Some("npm i -g @xai-official/grok@latest")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_semver() {
|
||||
use std::cmp::Ordering;
|
||||
@@ -3903,6 +3938,18 @@ mod tests {
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_windows_anchors_to_sibling_npm() {
|
||||
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "grok.cmd", &["npm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
|
||||
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
|
||||
let expected = format!(
|
||||
"{} i -g @xai-official/grok@latest",
|
||||
expect_quoted_path(&npm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_no_sibling_uses_cli_update_without_package_fallback() {
|
||||
// sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
|
||||
@@ -4223,6 +4270,9 @@ mod tests {
|
||||
let codex =
|
||||
wsl_tool_action_shell_command("codex", ToolLifecycleAction::Install).unwrap();
|
||||
assert_eq!(codex, "npm i -g @openai/codex@latest");
|
||||
|
||||
let grok = wsl_tool_action_shell_command("grok", ToolLifecycleAction::Install).unwrap();
|
||||
assert_eq!(grok, "npm i -g @xai-official/grok@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4383,6 +4433,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_nvm_anchors_to_npm_without_cli_update() {
|
||||
let cmd = anchored_command_from_paths(
|
||||
"grok",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/grok",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@xai-official/grok/bin/grok",
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @xai-official/grok@latest"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_nvm_anchors_to_that_npm() {
|
||||
// Codex 不走 self-update(`codex update` 在 npm 安装上只是裸 `npm install -g`,
|
||||
@@ -4833,6 +4898,12 @@ mod tests {
|
||||
assert_eq!(cmd, "npm i -g @google/gemini-cli@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_install_keeps_static_npm() {
|
||||
let cmd = install_command_for("grok");
|
||||
assert_eq!(cmd, "npm i -g @xai-official/grok@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openclaw_install_keeps_static_npm() {
|
||||
let cmd = install_command_for("openclaw");
|
||||
@@ -4855,6 +4926,11 @@ mod tests {
|
||||
"npm i -g @google/gemini-cli@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("gemini").contains("gemini update"));
|
||||
assert_eq!(
|
||||
static_fallback_command("grok"),
|
||||
"npm i -g @xai-official/grok@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("grok").contains("grok update"));
|
||||
assert_eq!(
|
||||
static_fallback_command("opencode"),
|
||||
"opencode upgrade || npm i -g opencode-ai@latest"
|
||||
|
||||
@@ -23,6 +23,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
|
||||
if takeover.claude
|
||||
|| takeover.codex
|
||||
|| takeover.gemini
|
||||
|| takeover.grokbuild
|
||||
|| takeover.opencode
|
||||
|| takeover.openclaw
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ impl Database {
|
||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
|
||||
FROM mcp_servers
|
||||
ORDER BY name ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -30,8 +30,9 @@ impl Database {
|
||||
let enabled_claude: bool = row.get(7)?;
|
||||
let enabled_codex: bool = row.get(8)?;
|
||||
let enabled_gemini: bool = row.get(9)?;
|
||||
let enabled_opencode: bool = row.get(10)?;
|
||||
let enabled_hermes: bool = row.get(11)?;
|
||||
let enabled_grokbuild: bool = row.get(10)?;
|
||||
let enabled_opencode: bool = row.get(11)?;
|
||||
let enabled_hermes: bool = row.get(12)?;
|
||||
|
||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
@@ -46,6 +47,7 @@ impl Database {
|
||||
claude: enabled_claude,
|
||||
codex: enabled_codex,
|
||||
gemini: enabled_gemini,
|
||||
grokbuild: enabled_grokbuild,
|
||||
opencode: enabled_opencode,
|
||||
hermes: enabled_hermes,
|
||||
},
|
||||
@@ -72,8 +74,8 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mcp_servers (
|
||||
id, name, server_config, description, homepage, docs, tags,
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
server.id,
|
||||
server.name,
|
||||
@@ -88,6 +90,7 @@ impl Database {
|
||||
server.apps.claude,
|
||||
server.apps.codex,
|
||||
server.apps.gemini,
|
||||
server.apps.grokbuild,
|
||||
server.apps.opencode,
|
||||
server.apps.hermes,
|
||||
],
|
||||
|
||||
@@ -328,6 +328,7 @@ impl Database {
|
||||
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
|
||||
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
"grokbuild" => (3, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
|
||||
};
|
||||
|
||||
@@ -398,6 +399,18 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// grokbuild: Responses protocol, same timeout defaults as Codex.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
enabled_hermes, installed_at, content_hash, updated_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -43,12 +43,13 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
},
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -67,8 +68,8 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
enabled_hermes, installed_at, content_hash, updated_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -87,12 +88,13 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
},
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
@@ -109,9 +111,9 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
|
||||
installed_at, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -124,6 +126,7 @@ impl Database {
|
||||
skill.apps.claude,
|
||||
skill.apps.codex,
|
||||
skill.apps.gemini,
|
||||
skill.apps.grokbuild,
|
||||
skill.apps.opencode,
|
||||
skill.apps.hermes,
|
||||
skill.installed_at,
|
||||
@@ -157,8 +160,8 @@ impl Database {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
|
||||
@@ -52,7 +52,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 13;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 15;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -65,7 +65,8 @@ impl Database {
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
@@ -93,6 +94,7 @@ impl Database {
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -122,7 +124,7 @@ impl Database {
|
||||
|
||||
// 8. Proxy Config 表(三行结构,app_type 主键)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -169,6 +171,15 @@ impl Database {
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 9. Provider Health 表
|
||||
@@ -485,6 +496,16 @@ impl Database {
|
||||
Self::migrate_v12_to_v13(conn)?;
|
||||
Self::set_user_version(conn, 13)?;
|
||||
}
|
||||
13 => {
|
||||
log::info!("迁移数据库从 v13 到 v14(添加 Grok Build 代理配置)");
|
||||
Self::migrate_v13_to_v14(conn)?;
|
||||
Self::set_user_version(conn, 14)?;
|
||||
}
|
||||
14 => {
|
||||
log::info!("迁移数据库从 v14 到 v15(Skills/MCP 添加 Grok Build 支持)");
|
||||
Self::migrate_v14_to_v15(conn)?;
|
||||
Self::set_user_version(conn, 15)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -798,12 +819,25 @@ impl Database {
|
||||
old_cb.3,
|
||||
old_cb.4,
|
||||
),
|
||||
(
|
||||
"grokbuild",
|
||||
false,
|
||||
false,
|
||||
3,
|
||||
old_config.4,
|
||||
old_config.5,
|
||||
old_cb.0,
|
||||
old_cb.1,
|
||||
old_cb.2,
|
||||
old_cb.3,
|
||||
old_cb.4,
|
||||
),
|
||||
];
|
||||
|
||||
// 创建新表
|
||||
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
|
||||
conn.execute("CREATE TABLE proxy_config_new (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -814,6 +848,7 @@ impl Database {
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
live_takeover_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", [])?;
|
||||
|
||||
@@ -1354,6 +1389,127 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v13 -> v14: allow Grok Build to own an independent proxy configuration row.
|
||||
fn migrate_v13_to_v14(conn: &Connection) -> Result<(), AppError> {
|
||||
if !Self::table_exists(conn, "proxy_config")? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.execute("DROP TABLE IF EXISTS proxy_config_v14", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"CREATE TABLE proxy_config_v14 (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721,
|
||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120,
|
||||
non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4,
|
||||
circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
||||
circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
live_takeover_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let copied_columns = [
|
||||
("app_type", "'claude'"),
|
||||
("proxy_enabled", "0"),
|
||||
("listen_address", "'127.0.0.1'"),
|
||||
("listen_port", "15721"),
|
||||
("enable_logging", "1"),
|
||||
("enabled", "0"),
|
||||
("auto_failover_enabled", "0"),
|
||||
("max_retries", "3"),
|
||||
("streaming_first_byte_timeout", "60"),
|
||||
("streaming_idle_timeout", "120"),
|
||||
("non_streaming_timeout", "600"),
|
||||
("circuit_failure_threshold", "4"),
|
||||
("circuit_success_threshold", "2"),
|
||||
("circuit_timeout_seconds", "60"),
|
||||
("circuit_error_rate_threshold", "0.6"),
|
||||
("circuit_min_requests", "10"),
|
||||
("default_cost_multiplier", "'1'"),
|
||||
("pricing_model_source", "'response'"),
|
||||
("live_takeover_active", "0"),
|
||||
("created_at", "datetime('now')"),
|
||||
("updated_at", "datetime('now')"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(column, fallback)| {
|
||||
Self::has_column(conn, "proxy_config", column).map(|exists| {
|
||||
if exists {
|
||||
format!("\"{column}\"")
|
||||
} else {
|
||||
fallback.into()
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, AppError>>()?
|
||||
.join(", ");
|
||||
|
||||
let copy_sql = format!(
|
||||
"INSERT INTO proxy_config_v14 (
|
||||
app_type, proxy_enabled, listen_address, listen_port, enable_logging,
|
||||
enabled, auto_failover_enabled, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests,
|
||||
default_cost_multiplier, pricing_model_source, live_takeover_active,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT {copied_columns} FROM proxy_config"
|
||||
);
|
||||
conn.execute(©_sql, [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute("DROP TABLE proxy_config", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute("ALTER TABLE proxy_config_v14 RENAME TO proxy_config", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES ('grokbuild')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v14 -> v15: persist Grok Build enablement for unified Skills and MCP.
|
||||
fn migrate_v14_to_v15(conn: &Connection) -> Result<(), AppError> {
|
||||
if Self::table_exists(conn, "mcp_servers")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"mcp_servers",
|
||||
"enabled_grokbuild",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
if Self::table_exists(conn, "skills")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skills",
|
||||
"enabled_grokbuild",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -2766,7 +2922,7 @@ mod tests {
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, 13);
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
&conn,
|
||||
"proxy_request_logs",
|
||||
@@ -2787,4 +2943,82 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_v13_to_v14_adds_grokbuild_proxy_row_and_preserves_values() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
Database::create_tables_on_conn(&conn)?;
|
||||
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET enabled = 1, max_retries = 9 WHERE app_type = 'codex'",
|
||||
[],
|
||||
)?;
|
||||
Database::set_user_version(&conn, 13)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
let grok_rows: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(grok_rows, 1);
|
||||
let codex_values: (i64, i64) = conn.query_row(
|
||||
"SELECT enabled, max_retries FROM proxy_config WHERE app_type = 'codex'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(codex_values, (1, 9));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_v14_to_v15_adds_grokbuild_skill_and_mcp_flags() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
||||
);",
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO mcp_servers (id, enabled_codex) VALUES ('mcp-1', 1)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO skills (id, enabled_codex) VALUES ('skill-1', 1)",
|
||||
[],
|
||||
)?;
|
||||
Database::set_user_version(&conn, 14)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
&conn,
|
||||
"mcp_servers",
|
||||
"enabled_grokbuild"
|
||||
)?);
|
||||
assert!(Database::has_column(&conn, "skills", "enabled_grokbuild")?);
|
||||
let mcp_values: (i64, i64) = conn.query_row(
|
||||
"SELECT enabled_codex, enabled_grokbuild FROM mcp_servers WHERE id = 'mcp-1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
let skill_values: (i64, i64) = conn.query_row(
|
||||
"SELECT enabled_codex, enabled_grokbuild FROM skills WHERE id = 'skill-1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(mcp_values, (1, 0));
|
||||
assert_eq!(skill_values, (1, 0));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +517,7 @@ fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||
.expect("count rows");
|
||||
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
|
||||
assert_eq!(count, 4, "per-app proxy_config should have 4 rows");
|
||||
|
||||
// 新结构下应能按 app_type 查询
|
||||
let _: i32 = conn
|
||||
@@ -657,7 +657,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
let proxy_rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||
.expect("count proxy_config rows");
|
||||
assert_eq!(proxy_rows, 3);
|
||||
assert_eq!(proxy_rows, 4);
|
||||
|
||||
// model_pricing 应具备默认数据(迁移时会 seed)
|
||||
let pricing_rows: i64 = conn
|
||||
|
||||
@@ -101,17 +101,7 @@ pub fn import_mcp_from_deeplink(
|
||||
// Server exists - merge apps only, keep other fields unchanged
|
||||
log::info!("MCP server '{id}' already exists, merging apps only");
|
||||
|
||||
let mut merged_apps = existing.apps.clone();
|
||||
// Merge new apps into existing apps
|
||||
if target_apps.claude {
|
||||
merged_apps.claude = true;
|
||||
}
|
||||
if target_apps.codex {
|
||||
merged_apps.codex = true;
|
||||
}
|
||||
if target_apps.gemini {
|
||||
merged_apps.gemini = true;
|
||||
}
|
||||
let merged_apps = merge_mcp_apps(&existing.apps, &target_apps);
|
||||
|
||||
McpServer {
|
||||
id: existing.id.clone(),
|
||||
@@ -166,6 +156,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
};
|
||||
@@ -175,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
"claude" => apps.claude = true,
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"grokbuild" | "grok" => apps.grokbuild = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
"openclaw" => {
|
||||
// OpenClaw doesn't support MCP, ignore silently
|
||||
@@ -197,3 +189,40 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
fn merge_mcp_apps(existing: &McpApps, target: &McpApps) -> McpApps {
|
||||
let mut merged = existing.clone();
|
||||
for app in target.enabled_apps() {
|
||||
merged.set_enabled_for(&app, true);
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enabled_apps_merge_covers_every_supported_mcp_client() {
|
||||
let existing = McpApps {
|
||||
claude: true,
|
||||
..McpApps::default()
|
||||
};
|
||||
let target = McpApps {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
hermes: true,
|
||||
..McpApps::default()
|
||||
};
|
||||
let merged = merge_mcp_apps(&existing, &target);
|
||||
|
||||
assert!(merged.claude);
|
||||
assert!(merged.codex);
|
||||
assert!(merged.gemini);
|
||||
assert!(merged.grokbuild);
|
||||
assert!(merged.opencode);
|
||||
assert!(merged.hermes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -190,10 +190,10 @@ fn parse_prompt_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -262,10 +262,17 @@ fn parse_mcp_deeplink(
|
||||
let trimmed = app.trim();
|
||||
if !matches!(
|
||||
trimmed,
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
"claude"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "grok"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::GrokBuild => build_grokbuild_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
AppType::OpenClaw => build_additive_app_settings(request),
|
||||
AppType::Hermes => build_hermes_settings(request),
|
||||
@@ -444,6 +445,36 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
json!({ "env": env })
|
||||
}
|
||||
|
||||
fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let model = request
|
||||
.model
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(crate::grok_config::DEFAULT_MODEL)
|
||||
.trim();
|
||||
let name = request
|
||||
.name
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("custom")
|
||||
.trim();
|
||||
let endpoint = get_primary_endpoint(request).trim().to_string();
|
||||
let api_key = request.api_key.as_deref().unwrap_or("").trim();
|
||||
|
||||
let model_value = toml_edit::Value::from(model).to_string();
|
||||
let name_value = toml_edit::Value::from(name).to_string();
|
||||
let endpoint_value = toml_edit::Value::from(endpoint.as_str()).to_string();
|
||||
let api_key_value = toml_edit::Value::from(api_key).to_string();
|
||||
|
||||
json!({
|
||||
"config": format!(
|
||||
"[models]\ndefault = {model_value}\n\n[model.{model_value}]\nmodel = {model_value}\nbase_url = {endpoint_value}\nname = {name_value}\napi_key = {api_key_value}\napi_backend = \"{}\"\ncontext_window = {}\n",
|
||||
crate::grok_config::DEFAULT_API_BACKEND,
|
||||
crate::grok_config::DEFAULT_CONTEXT_WINDOW,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build OpenCode settings configuration
|
||||
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
@@ -600,6 +631,7 @@ pub fn parse_and_merge_config(
|
||||
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
||||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||||
"grokbuild" => merge_grokbuild_config(&mut merged, &config_value)?,
|
||||
// Additive mode apps use JSON config directly; pass through as-is
|
||||
"openclaw" | "opencode" | "hermes" => {
|
||||
merge_additive_config(&mut merged, &config_value)?;
|
||||
@@ -774,6 +806,56 @@ fn merge_gemini_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_grokbuild_config(
|
||||
request: &mut DeepLinkImportRequest,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
let config_toml = if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) {
|
||||
config_toml.to_string()
|
||||
} else {
|
||||
let toml_value: toml::Value = serde_json::from_value(config.clone()).map_err(|error| {
|
||||
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
|
||||
})?;
|
||||
toml::to_string(&toml_value).map_err(|error| {
|
||||
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
|
||||
})?
|
||||
};
|
||||
let model = crate::grok_config::extract_model_config(&config_toml).ok_or_else(|| {
|
||||
AppError::InvalidInput("Invalid Grok Build config.toml model profile".to_string())
|
||||
})?;
|
||||
|
||||
if request
|
||||
.api_key
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
request.api_key = model.api_key.or_else(|| {
|
||||
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
|
||||
});
|
||||
}
|
||||
if request
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
request.endpoint = Some(model.base_url);
|
||||
}
|
||||
if request.model.is_none() {
|
||||
request.model = Some(model.model);
|
||||
}
|
||||
if request
|
||||
.homepage
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
if let Some(endpoint) = request.endpoint.as_deref() {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
|
||||
///
|
||||
/// These apps use JSON config directly, so we only extract common fields
|
||||
|
||||
@@ -87,6 +87,39 @@ fn test_parse_deeplink_with_notes() {
|
||||
assert_eq!(request.notes, Some("Test notes".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_provider() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
let url = "ccswitch://v1/import?resource=provider&app=grokbuild&name=Grok%20Relay&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=secret&model=grok-4.5";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(request.app.as_deref(), Some("grokbuild"));
|
||||
assert_eq!(request.name.as_deref(), Some("Grok Relay"));
|
||||
assert_eq!(
|
||||
request.endpoint.as_deref(),
|
||||
Some("https://api.example.com/v1")
|
||||
);
|
||||
assert_eq!(request.api_key.as_deref(), Some("secret"));
|
||||
assert_eq!(request.model.as_deref(), Some("grok-4.5"));
|
||||
|
||||
let provider = build_provider_from_request(&AppType::GrokBuild, &request).unwrap();
|
||||
let config = provider.settings_config["config"].as_str().unwrap();
|
||||
let document = config.parse::<toml::Value>().unwrap();
|
||||
let model = &document["model"]["grok-4.5"];
|
||||
|
||||
assert_eq!(document["models"]["default"].as_str(), Some("grok-4.5"));
|
||||
assert_eq!(
|
||||
model["base_url"].as_str(),
|
||||
Some("https://api.example.com/v1")
|
||||
);
|
||||
assert_eq!(model["name"].as_str(), Some("Grok Relay"));
|
||||
assert_eq!(model["api_key"].as_str(), Some("secret"));
|
||||
assert_eq!(model["api_backend"].as_str(), Some("responses"));
|
||||
assert_eq!(model["context_window"].as_integer(), Some(500_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_scheme() {
|
||||
let url = "https://v1/import?resource=provider&app=claude&name=Test";
|
||||
@@ -500,6 +533,38 @@ experimental_bearer_token = "sk-rightcode"
|
||||
assert_eq!(merged.model, Some("gpt-5-codex".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_merge_config_grokbuild() {
|
||||
let config_toml = r#"[models]
|
||||
default = "grok-profile"
|
||||
|
||||
[model."grok-profile"]
|
||||
model = "grok-upstream"
|
||||
base_url = "https://grok.example/v1"
|
||||
name = "Grok Relay"
|
||||
api_key = "sk-grok"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#;
|
||||
let config_json = serde_json::json!({ "config": config_toml }).to_string();
|
||||
let request = DeepLinkImportRequest {
|
||||
version: "v1".to_string(),
|
||||
resource: "provider".to_string(),
|
||||
app: Some("grokbuild".to_string()),
|
||||
name: Some("Grok Relay".to_string()),
|
||||
config: Some(BASE64_STANDARD.encode(config_json.as_bytes())),
|
||||
config_format: Some("json".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged = parse_and_merge_config(&request).expect("merge Grok Build config");
|
||||
|
||||
assert_eq!(merged.api_key.as_deref(), Some("sk-grok"));
|
||||
assert_eq!(merged.endpoint.as_deref(), Some("https://grok.example/v1"));
|
||||
assert_eq!(merged.model.as_deref(), Some("grok-upstream"));
|
||||
assert_eq!(merged.homepage.as_deref(), Some("https://grok.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_merge_config_url_override() {
|
||||
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
|
||||
@@ -709,6 +774,11 @@ fn test_parse_mcp_apps() {
|
||||
assert!(!apps.codex);
|
||||
assert!(apps.gemini);
|
||||
|
||||
let apps = parse_mcp_apps("grokbuild,opencode,hermes").unwrap();
|
||||
assert!(apps.grokbuild);
|
||||
assert!(apps.opencode);
|
||||
assert!(apps.hermes);
|
||||
|
||||
let err = parse_mcp_apps("invalid").unwrap_err();
|
||||
assert!(err.to_string().contains("Invalid app"));
|
||||
}
|
||||
@@ -731,6 +801,18 @@ fn test_parse_prompt_deeplink() {
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_prompt_deeplink() {
|
||||
let content_b64 = BASE64_STANDARD.encode("Grok instructions");
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=prompt&app=grokbuild&name=test&content={content_b64}"
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).expect("parse Grok Build prompt deeplink");
|
||||
|
||||
assert_eq!(request.app.as_deref(), Some("grokbuild"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_deeplink() {
|
||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||
@@ -747,6 +829,19 @@ fn test_parse_mcp_deeplink() {
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_mcp_deeplink() {
|
||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||
let config_b64 = BASE64_STANDARD.encode(config);
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=mcp&apps=grokbuild&config={config_b64}&enabled=true"
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).expect("parse Grok Build MCP deeplink");
|
||||
|
||||
assert_eq!(request.apps.as_deref(), Some("grokbuild"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_deeplink() {
|
||||
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{get_home_dir, write_text_file};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
|
||||
pub const DEFAULT_MODEL: &str = "grok-4.5";
|
||||
pub const DEFAULT_API_BACKEND: &str = "responses";
|
||||
pub const DEFAULT_CONTEXT_WINDOW: i64 = 500_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GrokModelConfig {
|
||||
pub profile: String,
|
||||
pub model: String,
|
||||
pub base_url: String,
|
||||
pub name: String,
|
||||
pub api_key: Option<String>,
|
||||
pub env_key: Option<String>,
|
||||
pub api_backend: String,
|
||||
pub context_window: i64,
|
||||
}
|
||||
|
||||
/// Grok Build configuration directory (`~/.grok`).
|
||||
pub fn get_grok_config_dir() -> PathBuf {
|
||||
crate::settings::get_grok_override_dir().unwrap_or_else(|| get_home_dir().join(".grok"))
|
||||
}
|
||||
|
||||
/// Grok Build live configuration path (`~/.grok/config.toml`).
|
||||
pub fn get_grok_config_path() -> PathBuf {
|
||||
get_grok_config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
fn required_non_empty_string<'a>(
|
||||
table: &'a toml::value::Table,
|
||||
key: &str,
|
||||
) -> Result<&'a str, AppError> {
|
||||
table
|
||||
.get(key)
|
||||
.and_then(toml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.field.missing",
|
||||
format!("Grok Build 配置缺少有效的 {key} 字段"),
|
||||
format!("Grok Build configuration is missing a valid {key} field"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_non_empty_string(table: &toml::value::Table, key: &str) -> Option<String> {
|
||||
table
|
||||
.get(key)
|
||||
.and_then(toml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
/// Validate the provider-owned Grok Build TOML document.
|
||||
pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> {
|
||||
let document = config_toml.parse::<toml::Value>().map_err(|error| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.invalid_toml",
|
||||
format!("Grok Build config.toml 格式错误: {error}"),
|
||||
format!("Invalid Grok Build config.toml: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let root = document.as_table().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.not_table",
|
||||
"Grok Build 配置必须是 TOML 表结构",
|
||||
"Grok Build configuration must be a TOML table",
|
||||
)
|
||||
})?;
|
||||
let models = root
|
||||
.get("models")
|
||||
.and_then(toml::Value::as_table)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.models.missing",
|
||||
"Grok Build 配置缺少 [models]",
|
||||
"Grok Build configuration is missing [models]",
|
||||
)
|
||||
})?;
|
||||
let default_model = required_non_empty_string(models, "default")?;
|
||||
let model_entries = root
|
||||
.get("model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.model.missing",
|
||||
"Grok Build 配置缺少 [model.<name>]",
|
||||
"Grok Build configuration is missing [model.<name>]",
|
||||
)
|
||||
})?;
|
||||
let selected_model = model_entries
|
||||
.get(default_model)
|
||||
.and_then(toml::Value::as_table)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.default_model.missing",
|
||||
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
|
||||
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
|
||||
)
|
||||
})?;
|
||||
|
||||
required_non_empty_string(selected_model, "model")?;
|
||||
required_non_empty_string(selected_model, "base_url")?;
|
||||
required_non_empty_string(selected_model, "name")?;
|
||||
if optional_non_empty_string(selected_model, "api_key").is_none()
|
||||
&& optional_non_empty_string(selected_model, "env_key").is_none()
|
||||
{
|
||||
return Err(AppError::localized(
|
||||
"provider.grokbuild.credentials.missing",
|
||||
"Grok Build 配置缺少有效的 api_key 或 env_key 字段",
|
||||
"Grok Build configuration is missing a valid api_key or env_key field",
|
||||
));
|
||||
}
|
||||
required_non_empty_string(selected_model, "api_backend")?;
|
||||
|
||||
selected_model
|
||||
.get("context_window")
|
||||
.and_then(toml::Value::as_integer)
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.context_window.invalid",
|
||||
"Grok Build context_window 必须是正整数",
|
||||
"Grok Build context_window must be a positive integer",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
|
||||
let document = config_toml.parse::<toml::Value>().ok()?;
|
||||
let root = document.as_table()?;
|
||||
let default_model = root
|
||||
.get("models")?
|
||||
.as_table()?
|
||||
.get("default")?
|
||||
.as_str()?
|
||||
.trim();
|
||||
let selected_model = root
|
||||
.get("model")?
|
||||
.as_table()?
|
||||
.get(default_model)?
|
||||
.as_table()?;
|
||||
Some(GrokModelConfig {
|
||||
profile: default_model.to_string(),
|
||||
model: selected_model.get("model")?.as_str()?.trim().to_string(),
|
||||
base_url: selected_model
|
||||
.get("base_url")?
|
||||
.as_str()?
|
||||
.trim_end_matches('/')
|
||||
.to_string(),
|
||||
name: selected_model.get("name")?.as_str()?.trim().to_string(),
|
||||
api_key: optional_non_empty_string(selected_model, "api_key"),
|
||||
env_key: optional_non_empty_string(selected_model, "env_key"),
|
||||
api_backend: selected_model
|
||||
.get("api_backend")?
|
||||
.as_str()?
|
||||
.trim()
|
||||
.to_string(),
|
||||
context_window: selected_model.get("context_window")?.as_integer()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
|
||||
let config = extract_model_config(config_toml)?;
|
||||
let api_key = config
|
||||
.api_key
|
||||
.or_else(|| {
|
||||
config
|
||||
.env_key
|
||||
.as_deref()
|
||||
.and_then(|key| std::env::var(key).ok())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.or_else(|| {
|
||||
std::env::var("XAI_API_KEY")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})?;
|
||||
Some((config.base_url, api_key))
|
||||
}
|
||||
|
||||
pub fn extract_inline_api_key(config_toml: &str) -> Option<String> {
|
||||
extract_model_config(config_toml)?.api_key
|
||||
}
|
||||
|
||||
pub fn extract_base_url(config_toml: &str) -> Option<String> {
|
||||
Some(extract_model_config(config_toml)?.base_url)
|
||||
}
|
||||
|
||||
fn update_selected_model_string(
|
||||
config_toml: &str,
|
||||
field: &str,
|
||||
value: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let mut document = config_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|error| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.invalid_toml",
|
||||
format!("Grok Build config.toml 格式错误: {error}"),
|
||||
format!("Invalid Grok Build config.toml: {error}"),
|
||||
)
|
||||
})?;
|
||||
let default_model = document
|
||||
.get("models")
|
||||
.and_then(|item| item.get("default"))
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|model| !model.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.default_model.missing",
|
||||
"Grok Build 配置缺少 models.default",
|
||||
"Grok Build configuration is missing models.default",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let selected_model = document
|
||||
.get_mut("model")
|
||||
.and_then(|item| item.get_mut(&default_model))
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.default_model.missing",
|
||||
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
|
||||
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
|
||||
)
|
||||
})?;
|
||||
selected_model.insert(field, toml_edit::value(value));
|
||||
Ok(document.to_string())
|
||||
}
|
||||
|
||||
pub fn apply_proxy_takeover(
|
||||
config_toml: &str,
|
||||
proxy_base_url: &str,
|
||||
token_placeholder: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
|
||||
update_selected_model_string(&updated, "api_key", token_placeholder)
|
||||
}
|
||||
|
||||
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
|
||||
update_selected_model_string(config_toml, "api_key", api_key)
|
||||
}
|
||||
|
||||
pub fn has_proxy_placeholder(config_toml: &str, token_placeholder: &str) -> bool {
|
||||
extract_model_config(config_toml)
|
||||
.and_then(|config| config.api_key)
|
||||
.is_some_and(|api_key| api_key == token_placeholder)
|
||||
}
|
||||
|
||||
pub fn base_url_matches(config_toml: &str, predicate: impl FnOnce(&str) -> bool) -> bool {
|
||||
extract_model_config(config_toml).is_some_and(|config| predicate(&config.base_url))
|
||||
}
|
||||
|
||||
/// Remove MCP projections from a provider-owned Grok Build settings snapshot.
|
||||
/// MCP servers are owned by the database and projected into live config.toml.
|
||||
pub fn strip_grok_mcp_servers_from_settings(settings: &mut Value) -> Result<(), AppError> {
|
||||
let Some(config_text) = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if !config_text.contains("mcp") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut document = config_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|error| AppError::Message(format!("Invalid Grok Build config.toml: {error}")))?;
|
||||
let mut changed = document.as_table_mut().remove("mcp_servers").is_some();
|
||||
if let Some(mcp_table) = document
|
||||
.get_mut("mcp")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
{
|
||||
if mcp_table.remove("servers").is_some() {
|
||||
changed = true;
|
||||
}
|
||||
if mcp_table.is_empty() {
|
||||
document.as_table_mut().remove("mcp");
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if let Some(object) = settings.as_object_mut() {
|
||||
object.insert("config".to_string(), Value::String(document.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_grok_live_settings() -> Result<Value, AppError> {
|
||||
let path = get_grok_config_path();
|
||||
if !path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"grokbuild.config.missing",
|
||||
"Grok Build 配置文件不存在",
|
||||
"Grok Build configuration file not found",
|
||||
));
|
||||
}
|
||||
|
||||
let config = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
|
||||
validate_config_toml(&config)?;
|
||||
Ok(json!({ "config": config }))
|
||||
}
|
||||
|
||||
pub fn write_grok_provider_live(provider: &Provider) -> Result<(), AppError> {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.settings.not_object",
|
||||
"Grok Build 配置必须是 JSON 对象",
|
||||
"Grok Build configuration must be a JSON object",
|
||||
)
|
||||
})?;
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
|
||||
write_grok_live_settings(&json!({ "config": config }))
|
||||
}
|
||||
|
||||
pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> {
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
validate_config_toml(config)?;
|
||||
write_text_file(&get_grok_config_path(), config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn valid_config() -> &'static str {
|
||||
r#"[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://example.com/v1"
|
||||
name = "Example"
|
||||
api_key = "secret"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}
|
||||
|
||||
fn valid_env_key_config() -> &'static str {
|
||||
r#"[models]
|
||||
default = "grok-env"
|
||||
|
||||
[model."grok-env"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://example.com/v1"
|
||||
name = "Example Env"
|
||||
env_key = "GROK_TEST_API_KEY"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_expected_config_shape() {
|
||||
validate_config_toml(valid_config()).expect("valid Grok Build config");
|
||||
validate_config_toml(valid_env_key_config()).expect("valid env_key configuration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_selected_model_table() {
|
||||
let error = validate_config_toml("[models]\ndefault = \"grok-4.5\"\n")
|
||||
.expect_err("missing model table should fail");
|
||||
assert!(error.to_string().contains("model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_config_without_api_key_or_env_key() {
|
||||
let config = valid_config().replace("api_key = \"secret\"\n", "");
|
||||
let error = validate_config_toml(&config).expect_err("credentials should be required");
|
||||
assert!(error.to_string().contains("api_key"));
|
||||
assert!(error.to_string().contains("env_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_selected_model_and_updates_takeover_fields() {
|
||||
let selected = extract_model_config(valid_config()).expect("selected model");
|
||||
assert_eq!(selected.profile, "grok-4.5");
|
||||
assert_eq!(selected.model, "grok-4.5");
|
||||
assert_eq!(selected.base_url, "https://example.com/v1");
|
||||
|
||||
let updated = apply_proxy_takeover(
|
||||
valid_config(),
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
.expect("takeover config");
|
||||
let selected = extract_model_config(&updated).expect("updated selected model");
|
||||
assert_eq!(selected.base_url, "http://127.0.0.1:15721/grokbuild/v1");
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
assert!(has_proxy_placeholder(&updated, "PROXY_MANAGED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
|
||||
let updated = apply_proxy_takeover(
|
||||
valid_env_key_config(),
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
.expect("takeover config");
|
||||
let selected = extract_model_config(&updated).expect("updated selected model");
|
||||
|
||||
assert_eq!(selected.profile, "grok-env");
|
||||
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolves_api_key_from_configured_environment_variable() {
|
||||
let original = std::env::var_os("GROK_TEST_API_KEY");
|
||||
std::env::set_var("GROK_TEST_API_KEY", "env-secret");
|
||||
|
||||
let credentials = extract_credentials(valid_env_key_config()).expect("credentials");
|
||||
|
||||
assert_eq!(credentials.0, "https://example.com/v1");
|
||||
assert_eq!(credentials.1, "env-secret");
|
||||
match original {
|
||||
Some(value) => std::env::set_var("GROK_TEST_API_KEY", value),
|
||||
None => std::env::remove_var("GROK_TEST_API_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_projected_mcp_servers_without_touching_model_config() {
|
||||
let mut settings = json!({
|
||||
"config": format!(
|
||||
"{}\n[mcp_servers.echo]\ncommand = \"echo\"\n",
|
||||
valid_config()
|
||||
)
|
||||
});
|
||||
|
||||
strip_grok_mcp_servers_from_settings(&mut settings).expect("strip MCP servers");
|
||||
|
||||
let config = settings.get("config").and_then(Value::as_str).unwrap();
|
||||
assert!(!config.contains("mcp_servers"));
|
||||
assert!(config.contains("model = \"grok-4.5\""));
|
||||
validate_config_toml(config).expect("stripped config remains valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn writes_and_reads_live_config() {
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"grok".to_string(),
|
||||
"Example".to_string(),
|
||||
json!({ "config": valid_config() }),
|
||||
None,
|
||||
);
|
||||
write_grok_provider_live(&provider).expect("write live config");
|
||||
|
||||
let path = get_grok_config_path();
|
||||
assert_eq!(path, temp.path().join(".grok").join("config.toml"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(path).expect("read config"),
|
||||
valid_config()
|
||||
);
|
||||
assert_eq!(
|
||||
read_grok_live_settings()
|
||||
.expect("read live settings")
|
||||
.get("config")
|
||||
.and_then(Value::as_str),
|
||||
Some(valid_config())
|
||||
);
|
||||
|
||||
match original_test_home {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-13
@@ -14,6 +14,7 @@ mod deeplink;
|
||||
mod error;
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod grok_config;
|
||||
pub mod hermes_config;
|
||||
mod init_status;
|
||||
mod lightweight;
|
||||
@@ -47,10 +48,11 @@ pub use database::{Database, Profile};
|
||||
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||
pub use error::AppError;
|
||||
pub use mcp::{
|
||||
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
|
||||
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
|
||||
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
|
||||
sync_single_server_to_codex, sync_single_server_to_gemini,
|
||||
import_from_claude, import_from_codex, import_from_gemini, import_from_grokbuild,
|
||||
remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini,
|
||||
remove_server_from_grokbuild, sync_enabled_to_claude, sync_enabled_to_codex,
|
||||
sync_enabled_to_gemini, sync_single_server_to_claude, sync_single_server_to_codex,
|
||||
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
|
||||
};
|
||||
pub use prompt::Prompt;
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
@@ -783,6 +785,14 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_grokbuild(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from Grok Build");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No Grok Build MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Grok Build MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
|
||||
@@ -808,6 +818,7 @@ pub fn run() {
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
crate::app_config::AppType::GrokBuild,
|
||||
crate::app_config::AppType::OpenCode,
|
||||
crate::app_config::AppType::OpenClaw,
|
||||
crate::app_config::AppType::Hermes,
|
||||
@@ -1740,16 +1751,25 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
|
||||
///
|
||||
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
|
||||
/// 则自动启动代理服务并接管对应应用的 Live 配置。
|
||||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||
let mut apps_to_restore = Vec::new();
|
||||
for app_type in ["claude", "codex", "gemini"] {
|
||||
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
|
||||
if config.enabled {
|
||||
apps_to_restore.push(app_type);
|
||||
}
|
||||
const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"];
|
||||
|
||||
async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> {
|
||||
let mut apps = Vec::new();
|
||||
for app_type in PROXY_STARTUP_APP_TYPES {
|
||||
if db
|
||||
.get_proxy_config_for_app(app_type)
|
||||
.await
|
||||
.is_ok_and(|config| config.enabled)
|
||||
{
|
||||
apps.push(app_type);
|
||||
}
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||
let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await;
|
||||
|
||||
if apps_to_restore.is_empty() {
|
||||
log::debug!("启动时无需恢复代理状态");
|
||||
@@ -2067,7 +2087,8 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_exit_request, ExitRequestAction};
|
||||
use super::{classify_exit_request, enabled_proxy_apps_on_startup, ExitRequestAction};
|
||||
use crate::database::Database;
|
||||
|
||||
#[test]
|
||||
fn no_code_keeps_app_alive_in_tray() {
|
||||
@@ -2093,4 +2114,21 @@ mod tests {
|
||||
ExitRequestAction::CleanupAndExit
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_restore_includes_enabled_grokbuild_route() {
|
||||
let db = Database::memory().expect("initialize database");
|
||||
let mut config = db
|
||||
.get_proxy_config_for_app("grokbuild")
|
||||
.await
|
||||
.expect("read Grok Build proxy config");
|
||||
config.enabled = true;
|
||||
db.update_proxy_config_for_app(config)
|
||||
.await
|
||||
.expect("enable Grok Build proxy config");
|
||||
|
||||
let apps = enabled_proxy_apps_on_startup(&db).await;
|
||||
|
||||
assert_eq!(apps, vec!["grokbuild"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -556,7 +557,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
|
||||
/// 1. 核心字段(type, command, args, url, headers, env, cwd)使用强类型处理
|
||||
/// 2. 扩展字段(timeout、retry 等)通过白名单列表自动转换
|
||||
/// 3. 其他未知字段使用通用转换器尝试转换
|
||||
fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
use toml_edit::{Array, Item, Table};
|
||||
|
||||
let mut t = Table::new();
|
||||
|
||||
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Grok Build MCP synchronization and import.
|
||||
//!
|
||||
//! Grok Build uses the same top-level `[mcp_servers]` TOML layout as Codex,
|
||||
//! stored alongside its model configuration in `~/.grok/config.toml`.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::codex::json_server_to_toml_table;
|
||||
use super::validation::validate_server_spec;
|
||||
|
||||
fn should_sync_grokbuild_mcp() -> bool {
|
||||
crate::grok_config::get_grok_config_dir().exists()
|
||||
}
|
||||
|
||||
fn read_config_text() -> Result<String, AppError> {
|
||||
let path = crate::grok_config::get_grok_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
|
||||
}
|
||||
|
||||
fn json_server_to_grokbuild_toml_table(server_spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
let mut table = json_server_to_toml_table(server_spec)?;
|
||||
// Grok infers transport from `command` or `url` and uses `headers`, while
|
||||
// Codex writes an explicit `type` plus `http_headers`.
|
||||
table.remove("type");
|
||||
if let Some(headers) = table.remove("http_headers") {
|
||||
table.insert("headers", headers);
|
||||
}
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
fn toml_server_to_json(entry: &toml::value::Table) -> Value {
|
||||
fn convert(value: &toml::Value) -> Option<Value> {
|
||||
match value {
|
||||
toml::Value::String(value) => Some(json!(value)),
|
||||
toml::Value::Integer(value) => Some(json!(value)),
|
||||
toml::Value::Float(value) => Some(json!(value)),
|
||||
toml::Value::Boolean(value) => Some(json!(value)),
|
||||
toml::Value::Datetime(value) => Some(json!(value.to_string())),
|
||||
toml::Value::Array(values) => Some(Value::Array(
|
||||
values.iter().filter_map(convert).collect::<Vec<_>>(),
|
||||
)),
|
||||
toml::Value::Table(values) => Some(Value::Object(
|
||||
values
|
||||
.iter()
|
||||
.filter_map(|(key, value)| convert(value).map(|value| (key.clone(), value)))
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
let mut spec = serde_json::Map::new();
|
||||
for (key, value) in entry {
|
||||
let output_key = if key == "http_headers" {
|
||||
"headers"
|
||||
} else {
|
||||
key
|
||||
};
|
||||
if let Some(value) = convert(value) {
|
||||
spec.insert(output_key.to_string(), value);
|
||||
}
|
||||
}
|
||||
let default_type = if spec.contains_key("url") {
|
||||
"http"
|
||||
} else {
|
||||
"stdio"
|
||||
};
|
||||
spec.entry("type".to_string())
|
||||
.or_insert_with(|| json!(default_type));
|
||||
Value::Object(spec)
|
||||
}
|
||||
|
||||
pub fn import_from_grokbuild(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let text = read_config_text()?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let root: toml::Table = toml::from_str(&text)
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.grok/config.toml 失败: {e}")))?;
|
||||
let Some(entries) = root.get("mcp_servers").and_then(toml::Value::as_table) else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let servers = config
|
||||
.mcp
|
||||
.servers
|
||||
.get_or_insert_with(std::collections::HashMap::new);
|
||||
let mut changed = 0;
|
||||
for (id, entry) in entries {
|
||||
let Some(entry) = entry.as_table() else {
|
||||
continue;
|
||||
};
|
||||
let spec = toml_server_to_json(entry);
|
||||
if let Err(error) = validate_server_spec(&spec) {
|
||||
log::warn!("跳过无效 Grok Build MCP 项 '{id}': {error}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
if !existing.apps.grokbuild {
|
||||
existing.apps.grokbuild = true;
|
||||
changed += 1;
|
||||
}
|
||||
} else {
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec,
|
||||
apps: McpApps {
|
||||
grokbuild: true,
|
||||
..Default::default()
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn sync_single_server_to_grokbuild(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
if !should_sync_grokbuild_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
use toml_edit::Item;
|
||||
|
||||
let path = crate::grok_config::get_grok_config_path();
|
||||
let text = read_config_text()?;
|
||||
let mut doc = if text.trim().is_empty() {
|
||||
toml_edit::DocumentMut::new()
|
||||
} else {
|
||||
text.parse::<toml_edit::DocumentMut>().map_err(|e| {
|
||||
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
|
||||
})?
|
||||
};
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
|
||||
if !should_sync_grokbuild_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = crate::grok_config::get_grok_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let text = read_config_text()?;
|
||||
let mut doc = match text.parse::<toml_edit::DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(error) => {
|
||||
log::warn!("解析 Grok Build config.toml 失败: {error},跳过删除操作");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Some(servers) = doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_mut)
|
||||
{
|
||||
servers.remove(id);
|
||||
}
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_http_headers_to_unified_headers() {
|
||||
let entry: toml::value::Table = toml::from_str(
|
||||
r#"type = "http"
|
||||
url = "https://example.com/mcp"
|
||||
http_headers = { Authorization = "Bearer token" }
|
||||
"#,
|
||||
)
|
||||
.expect("parse table");
|
||||
let spec = toml_server_to_json(&entry);
|
||||
assert_eq!(spec["type"], "http");
|
||||
assert_eq!(spec["headers"]["Authorization"], "Bearer token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_grokbuild_remote_server_without_codex_only_fields() {
|
||||
let table = json_server_to_grokbuild_toml_table(&json!({
|
||||
"type": "http",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer token" }
|
||||
}))
|
||||
.expect("convert server");
|
||||
|
||||
assert!(!table.contains_key("type"));
|
||||
assert!(!table.contains_key("http_headers"));
|
||||
assert_eq!(
|
||||
table
|
||||
.get("headers")
|
||||
.and_then(toml_edit::Item::as_table)
|
||||
.and_then(|headers| headers.get("Authorization"))
|
||||
.and_then(toml_edit::Item::as_str),
|
||||
Some("Bearer token")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -315,6 +315,7 @@ pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: true,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod grokbuild;
|
||||
mod hermes;
|
||||
mod opencode;
|
||||
mod validation;
|
||||
@@ -30,6 +31,9 @@ pub use gemini::{
|
||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||
sync_single_server_to_gemini,
|
||||
};
|
||||
pub use grokbuild::{
|
||||
import_from_grokbuild, remove_server_from_grokbuild, sync_single_server_to_grokbuild,
|
||||
};
|
||||
pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes};
|
||||
pub use opencode::{
|
||||
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
|
||||
|
||||
@@ -258,6 +258,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: true,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -12,9 +12,9 @@ use crate::opencode_config::get_opencode_dir;
|
||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.prompts_unsupported",
|
||||
"Claude Desktop 暂不支持 Prompts",
|
||||
"Claude Desktop does not support Prompts",
|
||||
"app.prompts_unsupported",
|
||||
"当前应用暂不支持 Prompts",
|
||||
"This app does not support Prompts",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||
AppType::Gemini => get_gemini_dir(),
|
||||
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
AppType::OpenClaw => get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
@@ -32,7 +33,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
|
||||
@@ -164,6 +164,11 @@ impl Provider {
|
||||
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||
(base_url, api_key)
|
||||
}
|
||||
AppType::GrokBuild => settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::grok_config::extract_credentials)
|
||||
.unwrap_or_default(),
|
||||
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
||||
AppType::Hermes => (
|
||||
str_at(settings.get("base_url")),
|
||||
|
||||
@@ -1142,9 +1142,9 @@ impl RequestForwarder {
|
||||
// Codex upstream conversion mode — computed early because the [1m]-suffix strip
|
||||
// below must be skipped on the Anthropic path (the marker has to survive to
|
||||
// catalog matching and to the transform's own strip+beta detection).
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint);
|
||||
let codex_official_auth_passthrough = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::is_codex_official_provider(provider);
|
||||
@@ -1168,6 +1168,13 @@ impl RequestForwarder {
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// Grok Build exposes a stable client-side model profile in config.toml.
|
||||
// Route requests to the provider's real upstream model before applying
|
||||
// the optional Responses -> Chat/Anthropic bridge.
|
||||
if matches!(app_type, AppType::GrokBuild) {
|
||||
super::providers::apply_codex_upstream_model(provider, &mut mapped_body);
|
||||
}
|
||||
|
||||
if is_copilot {
|
||||
mapped_body =
|
||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||
@@ -1511,7 +1518,7 @@ impl RequestForwarder {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
if matches!(app_type, AppType::Codex) {
|
||||
if matches!(app_type, AppType::Codex | AppType::GrokBuild) {
|
||||
self.apply_media_prevention(&mut request_body, provider);
|
||||
}
|
||||
|
||||
|
||||
@@ -758,6 +758,30 @@ pub async fn handle_chat_completions(
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await
|
||||
}
|
||||
|
||||
pub async fn handle_grokbuild_responses(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::GrokBuild,
|
||||
"Grok Build",
|
||||
"grokbuild",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_responses_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
@@ -774,7 +798,7 @@ pub async fn handle_responses(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
|
||||
let is_stream = body
|
||||
@@ -786,7 +810,7 @@ pub async fn handle_responses(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
&app_type,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
@@ -849,6 +873,30 @@ pub async fn handle_responses(
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await
|
||||
}
|
||||
|
||||
pub async fn handle_grokbuild_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_compact_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::GrokBuild,
|
||||
"Grok Build",
|
||||
"grokbuild",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_responses_compact_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
@@ -865,7 +913,7 @@ pub async fn handle_responses_compact(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
|
||||
let is_stream = body
|
||||
@@ -877,7 +925,7 @@ pub async fn handle_responses_compact(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
&app_type,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
|
||||
@@ -250,7 +250,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_codex_model_from_toml)
|
||||
.and_then(|config| {
|
||||
crate::grok_config::extract_model_config(config)
|
||||
.map(|model| model.model)
|
||||
.or_else(|| extract_codex_model_from_toml(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -621,6 +625,9 @@ impl CodexAdapter {
|
||||
}
|
||||
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some((_, key)) = crate::grok_config::extract_credentials(config_str) {
|
||||
return Some(key);
|
||||
}
|
||||
if let Some(key) =
|
||||
crate::codex_config::extract_codex_experimental_bearer_token(config_str)
|
||||
{
|
||||
@@ -675,6 +682,9 @@ impl ProviderAdapter for CodexAdapter {
|
||||
|
||||
// 尝试解析 TOML 字符串格式
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some(url) = crate::grok_config::extract_base_url(config_str) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
if let Some(start) = config_str.find("base_url = \"") {
|
||||
let rest = &config_str[start + 12..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
@@ -796,6 +806,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_build_toml_exposes_upstream_credentials_and_model() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"config": r#"
|
||||
[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "upstream-grok-model"
|
||||
base_url = "https://relay.example.com/v1/"
|
||||
name = "Example Relay"
|
||||
api_key = "grok-secret"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
adapter.extract_base_url(&provider).unwrap(),
|
||||
"https://relay.example.com/v1"
|
||||
);
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "grok-secret");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
assert_eq!(
|
||||
codex_provider_upstream_model(&provider).as_deref(),
|
||||
Some("upstream-grok-model")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
|
||||
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
|
||||
|
||||
@@ -192,10 +192,8 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex-like type
|
||||
ProviderType::Codex
|
||||
}
|
||||
AppType::GrokBuild => ProviderType::Codex,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,10 +244,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
AppType::GrokBuild => Box::new(CodexAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,12 @@ impl ProxyServer {
|
||||
.route("/v1/responses", post(handlers::handle_responses))
|
||||
.route("/v1/v1/responses", post(handlers::handle_responses))
|
||||
.route("/codex/v1/responses", post(handlers::handle_responses))
|
||||
// Grok Build uses the Responses protocol but has an independent
|
||||
// provider namespace and failover queue.
|
||||
.route(
|
||||
"/grokbuild/v1/responses",
|
||||
post(handlers::handle_grokbuild_responses),
|
||||
)
|
||||
// OpenAI Responses Compact API (Codex CLI 远程压缩,透传)
|
||||
.route(
|
||||
"/responses/compact",
|
||||
@@ -344,6 +350,10 @@ impl ProxyServer {
|
||||
"/codex/v1/responses/compact",
|
||||
post(handlers::handle_responses_compact),
|
||||
)
|
||||
.route(
|
||||
"/grokbuild/v1/responses/compact",
|
||||
post(handlers::handle_grokbuild_responses_compact),
|
||||
)
|
||||
// Gemini API (支持带前缀和不带前缀)
|
||||
//
|
||||
// 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` /
|
||||
|
||||
@@ -113,6 +113,7 @@ pub struct ProxyTakeoverStatus {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub grokbuild: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ impl CostCalculator {
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
|
||||
Self::calculate_with_cache_semantics(
|
||||
usage,
|
||||
pricing,
|
||||
@@ -211,6 +211,25 @@ mod tests {
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_does_not_double_bill_cached_input() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 600,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
let pricing = ModelPricing::from_strings("10", "0", "1", "0").unwrap();
|
||||
|
||||
let cost = CostCalculator::calculate_for_app("grokbuild", &usage, &pricing, Decimal::ONE);
|
||||
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.004").unwrap());
|
||||
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.0006").unwrap());
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.0046").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_multiplier() {
|
||||
let usage = TokenUsage {
|
||||
|
||||
@@ -71,11 +71,12 @@ impl<'a> UsageLogger<'a> {
|
||||
};
|
||||
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
let input_token_semantics = if matches!(log.app_type.as_str(), "codex" | "gemini") {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
let input_token_semantics =
|
||||
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
@@ -459,4 +460,39 @@ mod tests {
|
||||
assert_eq!(error, Some("Internal Server Error".to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_logs_total_input_token_semantics() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let logger = UsageLogger::new(&db);
|
||||
let log = RequestLog {
|
||||
request_id: "grok-semantics".to_string(),
|
||||
provider_id: "grok-provider".to_string(),
|
||||
app_type: "grokbuild".to_string(),
|
||||
model: "grok-4.5".to_string(),
|
||||
request_model: "grok-4.5".to_string(),
|
||||
pricing_model: String::new(),
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms: 1,
|
||||
first_token_ms: None,
|
||||
status_code: 200,
|
||||
error_message: None,
|
||||
session_id: None,
|
||||
provider_type: Some("grokbuild".to_string()),
|
||||
is_streaming: false,
|
||||
cost_multiplier: "1".to_string(),
|
||||
};
|
||||
|
||||
logger.log_request(&log)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let semantics: i64 = conn.query_row(
|
||||
"SELECT input_token_semantics FROM proxy_request_logs WHERE request_id = 'grok-semantics'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ impl ConfigService {
|
||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::GrokBuild)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -125,6 +126,7 @@ impl ConfigService {
|
||||
// Claude Desktop 3P profiles are managed by claude_desktop_config.
|
||||
}
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||
AppType::GrokBuild => crate::grok_config::write_grok_provider_live(&provider)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
|
||||
@@ -37,6 +37,9 @@ impl McpService {
|
||||
if prev_apps.gemini && !server.apps.gemini {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
||||
}
|
||||
if prev_apps.grokbuild && !server.apps.grokbuild {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::GrokBuild)?;
|
||||
}
|
||||
if prev_apps.opencode && !server.apps.opencode {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
|
||||
}
|
||||
@@ -122,6 +125,13 @@ impl McpService {
|
||||
AppType::Gemini => {
|
||||
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
mcp::sync_single_server_to_grokbuild(
|
||||
&Default::default(),
|
||||
&server.id,
|
||||
&server.server,
|
||||
)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
mcp::sync_single_server_to_opencode(
|
||||
&Default::default(),
|
||||
@@ -162,6 +172,7 @@ impl McpService {
|
||||
}
|
||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||
AppType::GrokBuild => mcp::remove_server_from_grokbuild(id)?,
|
||||
AppType::OpenCode => {
|
||||
mcp::remove_server_from_opencode(id)?;
|
||||
}
|
||||
@@ -393,6 +404,32 @@ impl McpService {
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 Grok Build 的 `[mcp_servers]` 导入 MCP。
|
||||
pub fn import_from_grokbuild(state: &AppState) -> Result<usize, AppError> {
|
||||
let mut temp_config = crate::app_config::MultiAppConfig::default();
|
||||
let count = crate::mcp::import_from_grokbuild(&mut temp_config)?;
|
||||
let mut new_count = 0;
|
||||
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
let mut existing = state.db.get_all_mcp_servers()?;
|
||||
for server in servers.values() {
|
||||
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||
let mut merged = existing_server.clone();
|
||||
merged.apps.grokbuild = true;
|
||||
merged
|
||||
} else {
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 OpenCode 导入 MCP(v3.9.2+ 新增)
|
||||
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
|
||||
// 创建临时 MultiAppConfig 用于导入
|
||||
@@ -479,10 +516,11 @@ impl McpService {
|
||||
let mut total = 0;
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
let results: [(&str, Result<usize, AppError>); 5] = [
|
||||
let results: [(&str, Result<usize, AppError>); 6] = [
|
||||
("claude", Self::import_from_claude(state)),
|
||||
("codex", Self::import_from_codex(state)),
|
||||
("gemini", Self::import_from_gemini(state)),
|
||||
("grokbuild", Self::import_from_grokbuild(state)),
|
||||
("opencode", Self::import_from_opencode(state)),
|
||||
("hermes", Self::import_from_hermes(state)),
|
||||
];
|
||||
|
||||
@@ -523,7 +523,11 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false,
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,9 +597,11 @@ pub(crate) fn remove_common_config_from_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,9 +656,11 @@ fn apply_common_config_to_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,6 +833,16 @@ fn restore_live_settings_for_provider_backfill(
|
||||
strip_injected_kimi_for_coding_context_defaults(&mut settings, provider);
|
||||
return settings;
|
||||
}
|
||||
if matches!(app_type, AppType::GrokBuild) {
|
||||
let mut settings = live_settings;
|
||||
if let Err(err) = crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings) {
|
||||
log::warn!(
|
||||
"Failed to strip Grok Build mcp_servers while backfilling '{}': {err}",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
if !matches!(app_type, AppType::Codex) {
|
||||
return live_settings;
|
||||
}
|
||||
@@ -1037,6 +1055,9 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
write_gemini_live(provider)?;
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
crate::grok_config::write_grok_provider_live(provider)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - write provider to config
|
||||
use crate::opencode_config;
|
||||
@@ -1367,6 +1388,7 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
AppType::GrokBuild => crate::grok_config::read_grok_live_settings(),
|
||||
AppType::OpenClaw => {
|
||||
use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};
|
||||
|
||||
@@ -1435,6 +1457,11 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
|
||||
AppType::GrokBuild => {
|
||||
let mut settings = crate::grok_config::read_grok_live_settings()?;
|
||||
crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings)?;
|
||||
settings
|
||||
}
|
||||
AppType::Claude => {
|
||||
let settings_path = get_claude_settings_path();
|
||||
if !settings_path.exists() {
|
||||
@@ -2536,4 +2563,32 @@ base_url = "https://a.example/v1"
|
||||
"non-MCP content must survive the strip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_switch_backfill_strips_synced_mcp_servers() {
|
||||
let provider = Provider::with_id(
|
||||
"grok".to_string(),
|
||||
"Grok".to_string(),
|
||||
json!({
|
||||
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let live_settings = json!({
|
||||
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n\n[mcp_servers.echo]\ncommand = \"echo\"\n"
|
||||
});
|
||||
|
||||
let result = restore_live_settings_for_provider_backfill(
|
||||
&AppType::GrokBuild,
|
||||
&provider,
|
||||
live_settings,
|
||||
);
|
||||
let config_text = result
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.expect("config text");
|
||||
|
||||
assert!(!config_text.contains("mcp_servers"));
|
||||
assert!(config_text.contains("model = \"grok-4.5\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2527,14 +2527,16 @@ impl ProviderService {
|
||||
// restore backup. Serialize them per app, then decide from the locked
|
||||
// current state so a just-started takeover cannot be overwritten by a
|
||||
// normal live write.
|
||||
let _switch_guard =
|
||||
if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {
|
||||
Some(futures::executor::block_on(
|
||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _switch_guard = if matches!(
|
||||
app_type,
|
||||
AppType::Claude | AppType::Codex | AppType::Gemini | AppType::GrokBuild
|
||||
) {
|
||||
Some(futures::executor::block_on(
|
||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Backup or live placeholders mean the live file is owned by proxy
|
||||
// takeover, even if the proxy server is temporarily stopped or is in the
|
||||
@@ -2993,6 +2995,7 @@ impl ProviderService {
|
||||
AppType::ClaudeDesktop => Ok(String::new()),
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::GrokBuild => Ok(String::new()),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||||
@@ -3009,6 +3012,7 @@ impl ProviderService {
|
||||
AppType::ClaudeDesktop => Ok(String::new()),
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::GrokBuild => Ok(String::new()),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||||
@@ -3479,6 +3483,26 @@ impl ProviderService {
|
||||
use crate::gemini_config::validate_gemini_settings;
|
||||
validate_gemini_settings(&provider.settings_config)?
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.settings.not_object",
|
||||
"Grok Build 配置必须是 JSON 对象",
|
||||
"Grok Build configuration must be a JSON object",
|
||||
)
|
||||
})?;
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
crate::grok_config::validate_config_toml(config)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses a different config structure: { npm, options, models }
|
||||
// Basic validation - must be an object
|
||||
@@ -3575,6 +3599,28 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let config_toml = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
let (base_url, api_key) = crate::grok_config::extract_credentials(config_toml)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.credentials.missing",
|
||||
"Grok Build 配置缺少 Base URL 或 API Key",
|
||||
"Grok Build configuration is missing the base URL or API key",
|
||||
)
|
||||
})?;
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::ClaudeDesktop => {
|
||||
let credentials =
|
||||
crate::claude_desktop_config::direct_gateway_credentials(provider)?;
|
||||
|
||||
+678
-47
File diff suppressed because it is too large
Load Diff
@@ -511,6 +511,11 @@ impl SkillService {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
if let Some(custom) = crate::settings::get_grok_override_dir() {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
if let Some(custom) = crate::settings::get_opencode_override_dir() {
|
||||
return Ok(custom.join("skills"));
|
||||
@@ -538,6 +543,7 @@ impl SkillService {
|
||||
AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"),
|
||||
AppType::Codex => home.join(".codex").join("skills"),
|
||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||
AppType::GrokBuild => home.join(".grok").join("skills"),
|
||||
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||
AppType::OpenClaw => home.join(".openclaw").join("skills"),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
/// style provider not added here) shows up loudly as a too-low cache hit
|
||||
/// rate, which is easier to catch than the silent over-deduction that
|
||||
/// would happen with the opposite default.
|
||||
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"];
|
||||
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
|
||||
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
|
||||
@@ -93,6 +93,7 @@ mod tests {
|
||||
assert!(!sql.contains("."));
|
||||
assert!(sql.contains("'codex'"));
|
||||
assert!(sql.contains("'gemini'"));
|
||||
assert!(sql.contains("'grokbuild'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -112,6 +113,13 @@ mod tests {
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Grok Build uses OpenAI Responses semantics too.
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
|
||||
VALUES ('grok-1', 'grokbuild', 700, 250)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Claude row: Anthropic semantics — input_tokens already excludes cache.
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
|
||||
@@ -123,8 +131,8 @@ mod tests {
|
||||
let expr = fresh_input_sql("l");
|
||||
let sql = format!("SELECT COALESCE(SUM({expr}), 0) FROM proxy_request_logs l");
|
||||
let total: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
|
||||
// Codex: 1000-600=400; Gemini: 800-300=500; Claude: 200 unchanged.
|
||||
assert_eq!(total, 400 + 500 + 200);
|
||||
// Codex: 400; Gemini: 500; Grok Build: 450; Claude: 200 unchanged.
|
||||
assert_eq!(total, 400 + 500 + 450 + 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,7 @@ pub mod terminal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use providers::{claude, codex, gemini, hermes, openclaw, opencode};
|
||||
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -56,13 +56,14 @@ pub struct DeleteSessionOutcome {
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| {
|
||||
let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| {
|
||||
let h1 = s.spawn(codex::scan_sessions);
|
||||
let h2 = s.spawn(claude::scan_sessions);
|
||||
let h3 = s.spawn(opencode::scan_sessions);
|
||||
let h4 = s.spawn(openclaw::scan_sessions);
|
||||
let h5 = s.spawn(gemini::scan_sessions);
|
||||
let h6 = s.spawn(hermes::scan_sessions);
|
||||
let h7 = s.spawn(grokbuild::scan_sessions);
|
||||
(
|
||||
h1.join().unwrap_or_default(),
|
||||
h2.join().unwrap_or_default(),
|
||||
@@ -70,6 +71,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
h4.join().unwrap_or_default(),
|
||||
h5.join().unwrap_or_default(),
|
||||
h6.join().unwrap_or_default(),
|
||||
h7.join().unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -80,6 +82,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
sessions.extend(r4);
|
||||
sessions.extend(r5);
|
||||
sessions.extend(r6);
|
||||
sessions.extend(r7);
|
||||
|
||||
sessions.sort_by(|a, b| {
|
||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||
@@ -106,6 +109,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
|
||||
"opencode" => opencode::load_messages(path),
|
||||
"openclaw" => openclaw::load_messages(path),
|
||||
"gemini" => gemini::load_messages(path),
|
||||
"grokbuild" => grokbuild::load_messages(path),
|
||||
"hermes" => hermes::load_messages(path),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
@@ -165,6 +169,9 @@ fn delete_session_with_roots(
|
||||
openclaw::delete_session(&validated_root, &validated_source, session_id)
|
||||
}
|
||||
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
|
||||
"grokbuild" => {
|
||||
grokbuild::delete_session(&validated_root, &validated_source, session_id)
|
||||
}
|
||||
"hermes" => hermes::delete_session(&validated_root, &validated_source, session_id),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
};
|
||||
@@ -194,6 +201,7 @@ fn provider_roots(provider_id: &str) -> Result<Vec<PathBuf>, String> {
|
||||
"opencode" => vec![opencode::get_opencode_data_dir()],
|
||||
"openclaw" => vec![crate::openclaw_config::get_openclaw_dir().join("agents")],
|
||||
"gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")],
|
||||
"grokbuild" => grokbuild::session_roots(),
|
||||
"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],
|
||||
_ => return Err(format!("Unsupported provider: {provider_id}")),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, truncate_summary, TITLE_MAX_CHARS};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GrokSessionInfo {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GrokSessionSummary {
|
||||
info: GrokSessionInfo,
|
||||
#[serde(default)]
|
||||
session_summary: Option<String>,
|
||||
#[serde(default)]
|
||||
generated_title: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Value>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Value>,
|
||||
#[serde(default)]
|
||||
last_active_at: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn session_roots() -> Vec<PathBuf> {
|
||||
let config_dir = crate::grok_config::get_grok_config_dir();
|
||||
vec![
|
||||
config_dir.join("sessions"),
|
||||
config_dir.join("archived_sessions"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let mut summaries = Vec::new();
|
||||
for root in session_roots() {
|
||||
collect_summary_files(&root, &mut summaries);
|
||||
}
|
||||
summaries
|
||||
.into_iter()
|
||||
.filter_map(|path| parse_summary(&path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let session_dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
|
||||
let chat_path = session_dir.join("chat_history.jsonl");
|
||||
let file = File::open(&chat_path)
|
||||
.map_err(|e| format!("Failed to open Grok Build chat history: {e}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let Ok(value) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let role = match kind {
|
||||
"system" | "user" | "assistant" | "tool" => kind,
|
||||
// Reasoning records can contain encrypted/internal state and are not
|
||||
// conversation messages shown by Grok's own history view.
|
||||
_ => continue,
|
||||
};
|
||||
let content = value.get("content").map(extract_text).unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ts = value
|
||||
.get("timestamp")
|
||||
.or_else(|| value.get("ts"))
|
||||
.and_then(parse_timestamp_to_ms);
|
||||
messages.push(SessionMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
ts,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
if !path.starts_with(root) {
|
||||
return Err(format!(
|
||||
"Grok Build session source is outside the session root: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.file_name().and_then(|name| name.to_str()) != Some("summary.json") {
|
||||
return Err(format!(
|
||||
"Unexpected Grok Build session source: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let summary = read_summary(path)?;
|
||||
if summary.info.id != session_id {
|
||||
return Err(format!(
|
||||
"Grok Build session ID mismatch: expected {session_id}, found {}",
|
||||
summary.info.id
|
||||
));
|
||||
}
|
||||
let session_dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
|
||||
if session_dir == root || !session_dir.starts_with(root) {
|
||||
return Err(format!(
|
||||
"Refusing to delete Grok Build session directory outside its root: {}",
|
||||
session_dir.display()
|
||||
));
|
||||
}
|
||||
if session_dir.file_name().and_then(|name| name.to_str()) != Some(session_id) {
|
||||
return Err(format!(
|
||||
"Grok Build session directory does not match session ID: {}",
|
||||
session_dir.display()
|
||||
));
|
||||
}
|
||||
std::fs::remove_dir_all(session_dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Grok Build session directory {}: {e}",
|
||||
session_dir.display()
|
||||
)
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn collect_summary_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_summary_files(&path, files);
|
||||
} else if path.file_name().and_then(|name| name.to_str()) == Some("summary.json") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_summary(path: &Path) -> Result<GrokSessionSummary, String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read Grok Build session summary: {e}"))?;
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|e| format!("Failed to parse Grok Build session summary: {e}"))
|
||||
}
|
||||
|
||||
fn parse_summary(path: &Path) -> Option<SessionMeta> {
|
||||
let summary = read_summary(path).ok()?;
|
||||
let session_id = summary.info.id;
|
||||
let title = summary
|
||||
.generated_title
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
summary
|
||||
.session_summary
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.map(|value| truncate_summary(value, TITLE_MAX_CHARS));
|
||||
let session_summary = summary
|
||||
.session_summary
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| truncate_summary(value, 160));
|
||||
let created_at = summary.created_at.as_ref().and_then(parse_timestamp_to_ms);
|
||||
let last_active_at = summary
|
||||
.last_active_at
|
||||
.as_ref()
|
||||
.or(summary.updated_at.as_ref())
|
||||
.and_then(parse_timestamp_to_ms);
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: "grokbuild".to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title,
|
||||
summary: session_summary,
|
||||
project_dir: summary.info.cwd,
|
||||
created_at,
|
||||
last_active_at,
|
||||
source_path: Some(path.to_string_lossy().to_string()),
|
||||
resume_command: Some(format!("grok --resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn scans_native_grokbuild_session_layout() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sessions_dir = temp.path().join("sessions");
|
||||
let session_id = "019f6af2-18b0-7673-958e-d25be650e172";
|
||||
let session_dir = sessions_dir.join("encoded-project").join(session_id);
|
||||
std::fs::create_dir_all(&session_dir).expect("create session dir");
|
||||
std::fs::write(
|
||||
session_dir.join("summary.json"),
|
||||
format!(
|
||||
r#"{{"info":{{"id":"{session_id}","cwd":"C:/work"}},"session_summary":"hello grok","generated_title":"Grok session","created_at":"2026-07-16T12:00:00Z","last_active_at":"2026-07-16T12:00:01Z"}}"#
|
||||
),
|
||||
)
|
||||
.expect("write summary");
|
||||
let mut files = Vec::new();
|
||||
collect_summary_files(&sessions_dir, &mut files);
|
||||
let sessions = files
|
||||
.iter()
|
||||
.filter_map(|path| parse_summary(path))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].provider_id, "grokbuild");
|
||||
assert_eq!(sessions[0].session_id, session_id);
|
||||
assert_eq!(sessions[0].title.as_deref(), Some("Grok session"));
|
||||
let expected_resume = format!("grok --resume {session_id}");
|
||||
assert_eq!(
|
||||
sessions[0].resume_command.as_deref(),
|
||||
Some(expected_resume.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_native_grokbuild_chat_history() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let summary_path = temp.path().join("summary.json");
|
||||
std::fs::write(&summary_path, "{}").expect("write summary placeholder");
|
||||
std::fs::write(
|
||||
temp.path().join("chat_history.jsonl"),
|
||||
concat!(
|
||||
"{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}\n",
|
||||
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"private\"}]}\n",
|
||||
"{\"type\":\"assistant\",\"content\":\"Hi there\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write chat history");
|
||||
|
||||
let messages = load_messages(&summary_path).expect("load messages");
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
assert_eq!(messages[0].content, "hello");
|
||||
assert_eq!(messages[1].content, "Hi there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_only_the_matching_session_directory() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let root = temp.path().join("sessions");
|
||||
let session_id = "session-to-delete";
|
||||
let session_dir = root.join("project").join(session_id);
|
||||
let sibling_dir = root.join("project").join("session-to-keep");
|
||||
std::fs::create_dir_all(&session_dir).expect("create session directory");
|
||||
std::fs::create_dir_all(&sibling_dir).expect("create sibling directory");
|
||||
let summary_path = session_dir.join("summary.json");
|
||||
std::fs::write(
|
||||
&summary_path,
|
||||
format!(r#"{{"info":{{"id":"{session_id}"}}}}"#),
|
||||
)
|
||||
.expect("write summary");
|
||||
std::fs::write(sibling_dir.join("keep.txt"), "keep").expect("write sibling file");
|
||||
|
||||
let deleted = delete_session(&root, &summary_path, session_id).expect("delete session");
|
||||
|
||||
assert!(deleted);
|
||||
assert!(!session_dir.exists());
|
||||
assert!(sibling_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_rejects_remove_dir_all_target_outside_root() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let root = temp.path().join("sessions");
|
||||
let outside_dir = temp.path().join("outside").join("session-outside");
|
||||
std::fs::create_dir_all(&root).expect("create root");
|
||||
std::fs::create_dir_all(&outside_dir).expect("create outside directory");
|
||||
let summary_path = outside_dir.join("summary.json");
|
||||
std::fs::write(&summary_path, r#"{"info":{"id":"session-outside"}}"#)
|
||||
.expect("write summary");
|
||||
|
||||
let error = delete_session(&root, &summary_path, "session-outside")
|
||||
.expect_err("outside path must be rejected");
|
||||
|
||||
assert!(error.contains("outside the session root"));
|
||||
assert!(outside_dir.exists());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod gemini;
|
||||
pub mod grokbuild;
|
||||
pub mod hermes;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct VisibleApps {
|
||||
#[serde(default = "default_true")]
|
||||
pub gemini: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub opencode: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub openclaw: bool,
|
||||
@@ -53,6 +55,7 @@ impl Default for VisibleApps {
|
||||
claude_desktop: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: false, // 默认不显示,需用户手动启用
|
||||
@@ -68,6 +71,7 @@ impl VisibleApps {
|
||||
AppType::ClaudeDesktop => self.claude_desktop,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::GrokBuild => self.grokbuild,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => self.openclaw,
|
||||
AppType::Hermes => self.hermes,
|
||||
@@ -411,6 +415,8 @@ pub struct AppSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gemini_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub grok_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openclaw_config_dir: Option<String>,
|
||||
@@ -430,6 +436,9 @@ pub struct AppSettings {
|
||||
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_gemini: Option<String>,
|
||||
/// 当前 Grok Build 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_grokbuild: Option<String>,
|
||||
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_opencode: Option<String>,
|
||||
@@ -520,6 +529,7 @@ impl Default for AppSettings {
|
||||
claude_config_dir: None,
|
||||
codex_config_dir: None,
|
||||
gemini_config_dir: None,
|
||||
grok_config_dir: None,
|
||||
opencode_config_dir: None,
|
||||
openclaw_config_dir: None,
|
||||
hermes_config_dir: None,
|
||||
@@ -527,6 +537,7 @@ impl Default for AppSettings {
|
||||
current_provider_claude_desktop: None,
|
||||
current_provider_codex: None,
|
||||
current_provider_gemini: None,
|
||||
current_provider_grokbuild: None,
|
||||
current_provider_opencode: None,
|
||||
current_provider_openclaw: None,
|
||||
current_provider_hermes: None,
|
||||
@@ -575,6 +586,13 @@ impl AppSettings {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.grok_config_dir = self
|
||||
.grok_config_dir
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.opencode_config_dir = self
|
||||
.opencode_config_dir
|
||||
.as_ref()
|
||||
@@ -883,6 +901,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_grok_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
.grok_config_dir
|
||||
.as_ref()
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_opencode_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
@@ -940,6 +966,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||||
AppType::ClaudeDesktop => settings.current_provider_claude_desktop.clone(),
|
||||
AppType::Codex => settings.current_provider_codex.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini.clone(),
|
||||
AppType::GrokBuild => settings.current_provider_grokbuild.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode.clone(),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
|
||||
AppType::Hermes => settings.current_provider_hermes.clone(),
|
||||
@@ -957,6 +984,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
|
||||
AppType::ClaudeDesktop => settings.current_provider_claude_desktop = id_owned.clone(),
|
||||
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
|
||||
AppType::GrokBuild => settings.current_provider_grokbuild = id_owned.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
|
||||
AppType::Hermes => settings.current_provider_hermes = id_owned.clone(),
|
||||
|
||||
+22
-2
@@ -118,7 +118,7 @@ pub struct TrayAppSection {
|
||||
pub const AUTO_SUFFIX: &str = "auto";
|
||||
pub const TRAY_ID: &str = "cc-switch";
|
||||
|
||||
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
pub const TRAY_SECTIONS: [TrayAppSection; 4] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Claude,
|
||||
prefix: "claude_",
|
||||
@@ -140,6 +140,13 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
header_label: "Gemini",
|
||||
log_name: "Gemini",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::GrokBuild,
|
||||
prefix: "grokbuild_",
|
||||
empty_id: "grokbuild_empty",
|
||||
header_label: "Grok Build",
|
||||
log_name: "Grok Build",
|
||||
},
|
||||
];
|
||||
|
||||
/// 配色阈值(与前端 `utilizationColor` 语义一致)。
|
||||
@@ -1072,7 +1079,8 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_script_summary, format_subscription_summary, TRAY_ID};
|
||||
use super::{format_script_summary, format_subscription_summary, TRAY_ID, TRAY_SECTIONS};
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
|
||||
@@ -1086,6 +1094,18 @@ mod tests {
|
||||
assert_ne!(TRAY_ID, "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_sections_include_grokbuild_provider_switching() {
|
||||
let section = TRAY_SECTIONS
|
||||
.iter()
|
||||
.find(|section| section.app_type == AppType::GrokBuild)
|
||||
.expect("Grok Build tray section should exist");
|
||||
|
||||
assert_eq!(section.prefix, "grokbuild_");
|
||||
assert_eq!(section.empty_id, "grokbuild_empty");
|
||||
assert_eq!(section.header_label, "Grok Build");
|
||||
}
|
||||
|
||||
fn make_quota(tool: &str, success: bool, tiers: Vec<QuotaTier>) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: tool.to_string(),
|
||||
|
||||
@@ -6,6 +6,14 @@ use cc_switch_lib::AppType;
|
||||
fn parse_known_apps_case_insensitive_and_trim() {
|
||||
assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude)));
|
||||
assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex)));
|
||||
assert!(matches!(
|
||||
AppType::from_str("grokbuild"),
|
||||
Ok(AppType::GrokBuild)
|
||||
));
|
||||
assert!(matches!(
|
||||
AppType::from_str("Grok-Build"),
|
||||
Ok(AppType::GrokBuild)
|
||||
));
|
||||
assert!(matches!(
|
||||
AppType::from_str(" ClAuDe \n"),
|
||||
Ok(AppType::Claude)
|
||||
|
||||
@@ -748,6 +748,7 @@ command = "echo"
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -877,6 +878,7 @@ fn import_from_claude_merges_into_config() {
|
||||
claude: false, // 初始未启用
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -227,6 +227,7 @@ command = "echo"
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -371,6 +372,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -436,6 +438,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -481,6 +484,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -515,6 +519,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -648,6 +653,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -703,6 +709,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -758,6 +765,7 @@ fn explicit_default_claude_dir_keeps_default_split_mcp_path() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -814,6 +822,7 @@ fn custom_claude_dir_writes_mcp_inside_config_dir() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -893,6 +902,7 @@ fn custom_claude_dir_sync_does_not_copy_default_profile() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1032,6 +1042,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1054,6 +1065,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,81 @@ fn settings_path(home: &Path) -> PathBuf {
|
||||
home.join(".cc-switch").join("settings.json")
|
||||
}
|
||||
|
||||
fn grokbuild_config(name: &str, endpoint: &str, api_key: &str) -> String {
|
||||
format!(
|
||||
r#"[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
base_url = "{endpoint}"
|
||||
name = "{name}"
|
||||
api_key = "{api_key}"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_import_and_switch_write_live_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let live_path = home.join(".grok").join("config.toml");
|
||||
std::fs::create_dir_all(live_path.parent().expect("grok config dir"))
|
||||
.expect("create grok config dir");
|
||||
let imported_config = grokbuild_config("Imported", "https://old.example/v1", "old-key");
|
||||
std::fs::write(&live_path, &imported_config).expect("seed Grok Build config");
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
import_default_config_test_hook(&state, AppType::GrokBuild)
|
||||
.expect("import Grok Build default provider");
|
||||
|
||||
let imported = state
|
||||
.db
|
||||
.get_provider_by_id("default", AppType::GrokBuild.as_str())
|
||||
.expect("query imported provider")
|
||||
.expect("imported provider exists");
|
||||
assert_eq!(
|
||||
imported
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some(imported_config.as_str())
|
||||
);
|
||||
|
||||
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
|
||||
state
|
||||
.db
|
||||
.save_provider(
|
||||
AppType::GrokBuild.as_str(),
|
||||
&Provider::with_id(
|
||||
"relay".to_string(),
|
||||
"Relay".to_string(),
|
||||
json!({ "config": next_config }),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.expect("save second Grok Build provider");
|
||||
|
||||
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
|
||||
.expect("switch Grok Build provider");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&live_path).expect("read switched Grok Build config"),
|
||||
next_config
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_provider(AppType::GrokBuild.as_str())
|
||||
.expect("read Grok Build current provider")
|
||||
.as_deref(),
|
||||
Some("relay")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
@@ -300,6 +375,7 @@ command = "say"
|
||||
claude: false,
|
||||
codex: true, // 启用 Codex
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -164,6 +164,7 @@ command = "say"
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -934,6 +935,7 @@ fn reapply_codex_official_live_resyncs_mcp_servers() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1032,6 +1034,7 @@ fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1124,6 +1127,7 @@ fn switch_codex_projects_mcp_despite_broken_claude_json() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1187,6 +1191,7 @@ fn sync_all_enabled_reports_broken_app_but_projects_the_rest() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ pub fn reset_test_fs() {
|
||||
".codex",
|
||||
".cc-switch",
|
||||
".gemini",
|
||||
".grok",
|
||||
".config",
|
||||
".openclaw",
|
||||
"profiles",
|
||||
|
||||
+8
-1
@@ -127,6 +127,7 @@ const VALID_APPS: AppId[] = [
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
@@ -194,6 +195,7 @@ function App() {
|
||||
"claude-desktop": true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: true,
|
||||
@@ -204,6 +206,7 @@ function App() {
|
||||
if (visibleApps["claude-desktop"]) return "claude-desktop";
|
||||
if (visibleApps.codex) return "codex";
|
||||
if (visibleApps.gemini) return "gemini";
|
||||
if (visibleApps.grokbuild) return "grokbuild";
|
||||
if (visibleApps.opencode) return "opencode";
|
||||
if (visibleApps.openclaw) return "openclaw";
|
||||
if (visibleApps.hermes) return "hermes";
|
||||
@@ -222,6 +225,7 @@ function App() {
|
||||
currentView === "sessions" &&
|
||||
sharedFeatureApp !== "claude" &&
|
||||
sharedFeatureApp !== "codex" &&
|
||||
sharedFeatureApp !== "grokbuild" &&
|
||||
sharedFeatureApp !== "opencode" &&
|
||||
sharedFeatureApp !== "openclaw" &&
|
||||
sharedFeatureApp !== "gemini" &&
|
||||
@@ -291,6 +295,7 @@ function App() {
|
||||
const hasSessionSupport =
|
||||
sharedFeatureApp === "claude" ||
|
||||
sharedFeatureApp === "codex" ||
|
||||
sharedFeatureApp === "grokbuild" ||
|
||||
sharedFeatureApp === "opencode" ||
|
||||
sharedFeatureApp === "openclaw" ||
|
||||
sharedFeatureApp === "gemini" ||
|
||||
@@ -1405,7 +1410,9 @@ function App() {
|
||||
? "openclaw"
|
||||
: activeApp === "hermes"
|
||||
? "hermes"
|
||||
: "default"
|
||||
: activeApp === "grokbuild"
|
||||
? "grokbuild"
|
||||
: "default"
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
|
||||
@@ -23,6 +23,7 @@ const ALL_APPS: AppId[] = [
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
@@ -46,6 +47,7 @@ export function AppSwitcher({
|
||||
"claude-desktop": "claude",
|
||||
codex: "openai",
|
||||
gemini: "gemini",
|
||||
grokbuild: "grok",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
hermes: "hermes",
|
||||
@@ -55,6 +57,7 @@ export function AppSwitcher({
|
||||
"claude-desktop": "Claude Desktop",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
grokbuild: "Grok Build",
|
||||
opencode: "OpenCode",
|
||||
openclaw: "OpenClaw",
|
||||
hermes: "Hermes",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
extractCodexBaseUrl,
|
||||
extractCodexExperimentalBearerToken,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { parseGrokBuildConfig } from "@/utils/grokBuildConfig";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import * as prettier from "prettier/standalone";
|
||||
import * as parserBabel from "prettier/parser-babel";
|
||||
@@ -262,6 +263,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
apiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY,
|
||||
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
|
||||
};
|
||||
} else if (appId === "grokbuild") {
|
||||
const grokConfig = parseGrokBuildConfig(
|
||||
(config as any).config,
|
||||
provider.name,
|
||||
);
|
||||
return {
|
||||
apiKey: grokConfig.apiKey,
|
||||
baseUrl: grokConfig.baseUrl,
|
||||
};
|
||||
} else if (appId === "hermes") {
|
||||
// Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml)
|
||||
return {
|
||||
|
||||
@@ -42,7 +42,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
onClose,
|
||||
existingIds = [],
|
||||
defaultFormat = "json",
|
||||
defaultEnabledApps = ["claude", "codex", "gemini"],
|
||||
defaultEnabledApps = ["claude", "codex", "gemini", "grokbuild"],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
||||
@@ -65,17 +65,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
grokbuild: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
}>(() => {
|
||||
if (initialData?.apps) {
|
||||
return { ...initialData.apps };
|
||||
return {
|
||||
...initialData.apps,
|
||||
grokbuild: initialData.apps.grokbuild ?? false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
claude: defaultEnabledApps.includes("claude"),
|
||||
codex: defaultEnabledApps.includes("codex"),
|
||||
gemini: defaultEnabledApps.includes("gemini"),
|
||||
grokbuild: defaultEnabledApps.includes("grokbuild"),
|
||||
opencode: defaultEnabledApps.includes("opencode"),
|
||||
openclaw: defaultEnabledApps.includes("openclaw"),
|
||||
hermes: defaultEnabledApps.includes("hermes"),
|
||||
@@ -566,6 +571,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-grokbuild"
|
||||
checked={enabledApps.grokbuild}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, grokbuild: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-grokbuild"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.grokbuild")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-opencode"
|
||||
|
||||
@@ -61,6 +61,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
"claude-desktop": 0,
|
||||
codex: 0,
|
||||
gemini: 0,
|
||||
grokbuild: 0,
|
||||
opencode: 0,
|
||||
openclaw: 0,
|
||||
hermes: 0,
|
||||
|
||||
@@ -35,6 +35,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||
"claude-desktop": "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
grokbuild: "AGENTS.md",
|
||||
opencode: "AGENTS.md",
|
||||
hermes: "AGENTS.md",
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
"claude-desktop": "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
grokbuild: "AGENTS.md",
|
||||
opencode: "AGENTS.md",
|
||||
openclaw: "AGENTS.md",
|
||||
hermes: "AGENTS.md",
|
||||
|
||||
@@ -19,6 +19,7 @@ import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets";
|
||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||
import { extractGrokBuildBaseUrl } from "@/utils/grokBuildConfig";
|
||||
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
|
||||
@@ -48,6 +49,7 @@ export function AddProviderDialog({
|
||||
appId !== "opencode" &&
|
||||
appId !== "openclaw" &&
|
||||
appId !== "hermes" &&
|
||||
appId !== "grokbuild" &&
|
||||
appId !== "claude-desktop";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
@@ -255,6 +257,11 @@ export function AddProviderDialog({
|
||||
if (env?.GOOGLE_GEMINI_BASE_URL) {
|
||||
addUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||
}
|
||||
} else if (appId === "grokbuild") {
|
||||
const config = parsedConfig.config as string | undefined;
|
||||
if (config) {
|
||||
addUrl(extractGrokBuildBaseUrl(config));
|
||||
}
|
||||
} else if (appId === "opencode") {
|
||||
const options = parsedConfig.options as
|
||||
| Record<string, any>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
|
||||
import { useProviderHealth } from "@/lib/query/failover";
|
||||
import { useUsageQuery } from "@/lib/query/queries";
|
||||
import { resolveProviderIcon } from "@/utils/providerIcon";
|
||||
|
||||
interface DragHandleProps {
|
||||
attributes: DraggableAttributes;
|
||||
@@ -350,7 +351,11 @@ export function ProviderCard({
|
||||
|
||||
<div className="h-8 w-8 flex-shrink-0 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||||
<ProviderIcon
|
||||
icon={provider.icon}
|
||||
icon={resolveProviderIcon(
|
||||
appId,
|
||||
provider.icon,
|
||||
provider.iconColor,
|
||||
)}
|
||||
name={provider.name}
|
||||
color={provider.iconColor}
|
||||
size={20}
|
||||
|
||||
@@ -43,12 +43,14 @@ import type {
|
||||
PromptCacheRoutingMode,
|
||||
ProviderCategory,
|
||||
} from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
|
||||
interface EndpointCandidate {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface CodexFormFieldsProps {
|
||||
appId?: AppId;
|
||||
providerId?: string;
|
||||
// API Key
|
||||
codexApiKey: string;
|
||||
@@ -155,6 +157,7 @@ function catalogRowsMatchModels(
|
||||
}
|
||||
|
||||
export function CodexFormFields({
|
||||
appId = "codex",
|
||||
providerId,
|
||||
codexApiKey,
|
||||
onApiKeyChange,
|
||||
@@ -1007,7 +1010,7 @@ export function CodexFormFields({
|
||||
{/* 端点测速弹窗 - Codex */}
|
||||
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||
<EndpointSpeedTest
|
||||
appId="codex"
|
||||
appId={appId}
|
||||
providerId={providerId}
|
||||
value={codexBaseUrl}
|
||||
onChange={onBaseUrlChange}
|
||||
|
||||
@@ -14,6 +14,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
|
||||
claude: 8,
|
||||
"claude-desktop": 8,
|
||||
gemini: 8,
|
||||
grokbuild: 12,
|
||||
opencode: 8,
|
||||
openclaw: 8,
|
||||
hermes: 8,
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import {
|
||||
buildLocalProxyRequestOverrides,
|
||||
formatRequestOverrideObject,
|
||||
} from "@/lib/requestOverrides";
|
||||
import type {
|
||||
ClaudeApiKeyField,
|
||||
CodexApiFormat,
|
||||
CodexChatReasoning,
|
||||
PromptCacheRoutingMode,
|
||||
ProviderCategory,
|
||||
ProviderMeta,
|
||||
} from "@/types";
|
||||
import type { ProviderFormProps, ProviderFormValues } from "./ProviderForm";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
import { CodexFormFields } from "./CodexFormFields";
|
||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||
import {
|
||||
codexProviderPresets,
|
||||
type CodexProviderPreset,
|
||||
} from "@/config/codexProviderPresets";
|
||||
import {
|
||||
codexApiFormatFromWireApi,
|
||||
extractCodexBaseUrl,
|
||||
extractCodexModelName,
|
||||
extractCodexWireApi,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import {
|
||||
buildGrokBuildConfig,
|
||||
parseGrokBuildConfig,
|
||||
updateGrokBuildConfig,
|
||||
validateGrokBuildConfig,
|
||||
} from "@/utils/grokBuildConfig";
|
||||
import { resolveProviderIcon } from "@/utils/providerIcon";
|
||||
|
||||
type GrokBuildProviderFormProps = Omit<ProviderFormProps, "appId">;
|
||||
|
||||
const grokPresetEntries: Array<{
|
||||
id: string;
|
||||
preset: CodexProviderPreset;
|
||||
}> = codexProviderPresets
|
||||
.map((preset, index) => ({ id: `grokbuild-${index}`, preset }))
|
||||
.filter(({ preset }) => preset.category !== "official" && !preset.isOfficial);
|
||||
|
||||
export const grokApiBackendFromApiFormat = (format: CodexApiFormat): string => {
|
||||
if (format === "openai_chat") return "chat_completions";
|
||||
if (format === "anthropic") return "messages";
|
||||
return "responses";
|
||||
};
|
||||
|
||||
export function GrokBuildProviderForm({
|
||||
providerId,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onSubmittingChange,
|
||||
initialData,
|
||||
showButtons = true,
|
||||
}: GrokBuildProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDarkMode = useDarkMode();
|
||||
const initialConfigText =
|
||||
typeof initialData?.settingsConfig?.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: undefined;
|
||||
const initialConfig = useMemo(
|
||||
() => parseGrokBuildConfig(initialConfigText, initialData?.name),
|
||||
[initialConfigText, initialData?.name],
|
||||
);
|
||||
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
|
||||
initialData ? null : "custom",
|
||||
);
|
||||
const [category, setCategory] = useState<ProviderCategory | undefined>(
|
||||
initialData?.category ?? "custom",
|
||||
);
|
||||
const [isPartner, setIsPartner] = useState(
|
||||
initialData?.meta?.isPartner ?? false,
|
||||
);
|
||||
const [partnerPromotionKey, setPartnerPromotionKey] = useState<string>();
|
||||
const [profile, setProfile] = useState(initialConfig.model);
|
||||
const [upstreamModel, setUpstreamModel] = useState(
|
||||
initialConfig.upstreamModel ?? initialConfig.model,
|
||||
);
|
||||
const [baseUrl, setBaseUrl] = useState(initialConfig.baseUrl);
|
||||
const [apiKey, setApiKey] = useState(initialConfig.apiKey);
|
||||
const [apiBackend, setApiBackend] = useState(initialConfig.apiBackend);
|
||||
const [contextWindow, setContextWindow] = useState(
|
||||
String(initialConfig.contextWindow),
|
||||
);
|
||||
const [rawConfig, setRawConfig] = useState(
|
||||
initialConfigText ?? buildGrokBuildConfig(initialConfig),
|
||||
);
|
||||
const [apiFormat, setApiFormat] = useState<CodexApiFormat>(
|
||||
(initialData?.meta?.apiFormat as CodexApiFormat | undefined) ??
|
||||
"openai_responses",
|
||||
);
|
||||
const [anthropicAuthField, setAnthropicAuthField] =
|
||||
useState<ClaudeApiKeyField>(
|
||||
initialData?.meta?.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN",
|
||||
);
|
||||
const [impersonateClaudeCode, setImpersonateClaudeCode] = useState(
|
||||
initialData?.meta?.impersonateClaudeCode === true,
|
||||
);
|
||||
const [maxOutputTokens, setMaxOutputTokens] = useState(
|
||||
initialData?.meta?.maxOutputTokens
|
||||
? String(initialData.meta.maxOutputTokens)
|
||||
: "",
|
||||
);
|
||||
const [codexChatReasoning, setCodexChatReasoning] =
|
||||
useState<CodexChatReasoning>(initialData?.meta?.codexChatReasoning ?? {});
|
||||
const [promptCacheRouting, setPromptCacheRouting] =
|
||||
useState<PromptCacheRoutingMode>(
|
||||
initialData?.meta?.promptCacheRouting ?? "auto",
|
||||
);
|
||||
const [isFullUrl, setIsFullUrl] = useState(
|
||||
initialData?.meta?.isFullUrl ?? false,
|
||||
);
|
||||
const [customUserAgent, setCustomUserAgent] = useState(
|
||||
initialData?.meta?.customUserAgent ?? "",
|
||||
);
|
||||
const [headersOverride, setHeadersOverride] = useState(
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.headers,
|
||||
),
|
||||
);
|
||||
const [bodyOverride, setBodyOverride] = useState(
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.body,
|
||||
),
|
||||
);
|
||||
const [endpointAutoSelect, setEndpointAutoSelect] = useState(
|
||||
initialData?.meta?.endpointAutoSelect ?? true,
|
||||
);
|
||||
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||||
const [presetEndpoints, setPresetEndpoints] = useState<string[]>([]);
|
||||
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const form = useForm<ProviderFormData>({
|
||||
resolver: zodResolver(providerSchema),
|
||||
defaultValues: {
|
||||
name: initialData?.name ?? initialConfig.name,
|
||||
websiteUrl: initialData?.websiteUrl ?? "",
|
||||
notes: initialData?.notes ?? "",
|
||||
settingsConfig: JSON.stringify({ config: rawConfig }),
|
||||
icon:
|
||||
resolveProviderIcon(
|
||||
"grokbuild",
|
||||
initialData?.icon,
|
||||
initialData?.iconColor,
|
||||
) ?? "",
|
||||
iconColor: initialData?.iconColor ?? "",
|
||||
},
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const { isSubmitting } = form.formState;
|
||||
const websiteUrl = form.watch("websiteUrl") ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
onSubmittingChange?.(isSubmitting);
|
||||
}, [isSubmitting, onSubmittingChange]);
|
||||
|
||||
const presetCategoryLabels = useMemo(
|
||||
() => ({
|
||||
official: t("providerForm.categoryOfficial", { defaultValue: "官方" }),
|
||||
cn_official: t("providerForm.categoryCnOfficial", {
|
||||
defaultValue: "国内官方",
|
||||
}),
|
||||
aggregator: t("providerForm.categoryAggregation", {
|
||||
defaultValue: "聚合服务",
|
||||
}),
|
||||
third_party: t("providerForm.categoryThirdParty", {
|
||||
defaultValue: "第三方",
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const speedTestEndpoints = useMemo(() => {
|
||||
const urls = new Set<string>();
|
||||
const add = (url?: string) => {
|
||||
const normalized = url?.trim().replace(/\/+$/, "");
|
||||
if (normalized) urls.add(normalized);
|
||||
};
|
||||
add(baseUrl);
|
||||
presetEndpoints.forEach(add);
|
||||
draftCustomEndpoints.forEach(add);
|
||||
return Array.from(urls).map((url) => ({ url }));
|
||||
}, [baseUrl, draftCustomEndpoints, presetEndpoints]);
|
||||
|
||||
const syncStructuredConfig = (
|
||||
overrides: Partial<ReturnType<typeof parseGrokBuildConfig>>,
|
||||
) => {
|
||||
const next = {
|
||||
model: profile,
|
||||
upstreamModel,
|
||||
baseUrl,
|
||||
name: form.getValues("name") || initialConfig.name,
|
||||
apiKey,
|
||||
apiBackend,
|
||||
contextWindow: Number.parseInt(contextWindow, 10),
|
||||
...overrides,
|
||||
};
|
||||
setRawConfig((current) => updateGrokBuildConfig(current, next));
|
||||
};
|
||||
|
||||
const handlePresetChange = (presetId: string) => {
|
||||
setSelectedPresetId(presetId);
|
||||
if (presetId === "custom") {
|
||||
setCategory("custom");
|
||||
setIsPartner(false);
|
||||
setPartnerPromotionKey(undefined);
|
||||
setPresetEndpoints([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = grokPresetEntries.find(
|
||||
(candidate) => candidate.id === presetId,
|
||||
);
|
||||
if (!entry) return;
|
||||
const preset = entry.preset;
|
||||
const presetName = preset.nameKey ? String(t(preset.nameKey)) : preset.name;
|
||||
const presetBaseUrl = extractCodexBaseUrl(preset.config) ?? "";
|
||||
const presetModel = extractCodexModelName(preset.config) ?? profile;
|
||||
const presetApiFormat =
|
||||
preset.apiFormat ??
|
||||
codexApiFormatFromWireApi(extractCodexWireApi(preset.config)) ??
|
||||
"openai_responses";
|
||||
const presetApiKey =
|
||||
"auth" in preset && typeof preset.auth?.OPENAI_API_KEY === "string"
|
||||
? preset.auth.OPENAI_API_KEY
|
||||
: "";
|
||||
const presetApiBackend = grokApiBackendFromApiFormat(presetApiFormat);
|
||||
|
||||
form.setValue("name", presetName);
|
||||
form.setValue("websiteUrl", preset.websiteUrl ?? "");
|
||||
form.setValue("icon", preset.icon ?? "");
|
||||
form.setValue("iconColor", preset.iconColor ?? "");
|
||||
setCategory(preset.category ?? "custom");
|
||||
setIsPartner(preset.isPartner ?? false);
|
||||
setPartnerPromotionKey(preset.partnerPromotionKey);
|
||||
setBaseUrl(presetBaseUrl);
|
||||
setApiKey(presetApiKey);
|
||||
setUpstreamModel(presetModel);
|
||||
setApiFormat(presetApiFormat);
|
||||
setApiBackend(presetApiBackend);
|
||||
setPresetEndpoints(preset.endpointCandidates ?? []);
|
||||
setRawConfig(
|
||||
buildGrokBuildConfig({
|
||||
model: profile,
|
||||
upstreamModel: presetModel,
|
||||
baseUrl: presetBaseUrl,
|
||||
name: presetName,
|
||||
apiKey: presetApiKey,
|
||||
apiBackend: presetApiBackend,
|
||||
contextWindow: Number.parseInt(contextWindow, 10),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRawConfigChange = (value: string) => {
|
||||
setRawConfig(value);
|
||||
if (validateGrokBuildConfig(value)) return;
|
||||
const parsed = parseGrokBuildConfig(value, form.getValues("name"));
|
||||
setProfile(parsed.model);
|
||||
setUpstreamModel(parsed.upstreamModel ?? parsed.model);
|
||||
setBaseUrl(parsed.baseUrl);
|
||||
setApiKey(parsed.apiKey);
|
||||
setApiBackend(parsed.apiBackend);
|
||||
setContextWindow(String(parsed.contextWindow));
|
||||
if (parsed.name) form.setValue("name", parsed.name);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: ProviderFormData) => {
|
||||
const name = values.name.trim();
|
||||
const parsedContextWindow = Number.parseInt(contextWindow, 10);
|
||||
const envKey = parseGrokBuildConfig(rawConfig).envKey?.trim();
|
||||
if (
|
||||
!name ||
|
||||
!baseUrl.trim() ||
|
||||
(!apiKey.trim() && !envKey) ||
|
||||
!profile.trim()
|
||||
) {
|
||||
toast.error(
|
||||
t("providerForm.requiredFields", {
|
||||
defaultValue: "请填写供应商名称、API 地址、API Key 和模型",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(parsedContextWindow) || parsedContextWindow <= 0) {
|
||||
toast.error(
|
||||
t("grokBuild.contextWindowInvalid", {
|
||||
defaultValue: "上下文窗口必须是正整数",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalConfig = updateGrokBuildConfig(rawConfig, {
|
||||
model: profile,
|
||||
upstreamModel,
|
||||
baseUrl,
|
||||
name,
|
||||
apiKey,
|
||||
apiBackend,
|
||||
contextWindow: parsedContextWindow,
|
||||
});
|
||||
const configError = validateGrokBuildConfig(finalConfig);
|
||||
if (configError) {
|
||||
toast.error(
|
||||
t("grokBuild.invalidToml", {
|
||||
error: configError,
|
||||
defaultValue: `config.toml 格式错误: ${configError}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestOverrides = buildLocalProxyRequestOverrides(
|
||||
headersOverride,
|
||||
bodyOverride,
|
||||
);
|
||||
if (requestOverrides.error) {
|
||||
toast.error(requestOverrides.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const customEndpoints = Object.fromEntries(
|
||||
draftCustomEndpoints.map((url) => [
|
||||
url,
|
||||
{ url, addedAt: Date.now(), lastUsed: undefined },
|
||||
]),
|
||||
);
|
||||
const parsedMaxOutputTokens = Number.parseInt(maxOutputTokens, 10);
|
||||
const initialMeta = { ...(initialData?.meta ?? {}) };
|
||||
delete initialMeta.custom_endpoints;
|
||||
const meta: ProviderMeta = {
|
||||
...initialMeta,
|
||||
apiFormat,
|
||||
apiKeyField: anthropicAuthField,
|
||||
isFullUrl,
|
||||
endpointAutoSelect,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
impersonateClaudeCode,
|
||||
promptCacheRouting,
|
||||
codexChatReasoning,
|
||||
customUserAgent: customUserAgent.trim() || undefined,
|
||||
localProxyRequestOverrides: requestOverrides.overrides,
|
||||
maxOutputTokens:
|
||||
Number.isInteger(parsedMaxOutputTokens) && parsedMaxOutputTokens > 0
|
||||
? parsedMaxOutputTokens
|
||||
: undefined,
|
||||
};
|
||||
if (!providerId && Object.keys(customEndpoints).length > 0) {
|
||||
meta.custom_endpoints = customEndpoints;
|
||||
}
|
||||
const payload: ProviderFormValues = {
|
||||
...values,
|
||||
name,
|
||||
websiteUrl: values.websiteUrl?.trim() ?? "",
|
||||
notes: values.notes?.trim() ?? "",
|
||||
settingsConfig: JSON.stringify({ config: finalConfig }),
|
||||
presetId: selectedPresetId ?? undefined,
|
||||
presetCategory: category ?? "custom",
|
||||
isPartner,
|
||||
meta,
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
const rawConfigError = validateGrokBuildConfig(rawConfig);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="provider-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{!initialData && (
|
||||
<ProviderPresetSelector
|
||||
selectedPresetId={selectedPresetId}
|
||||
presetEntries={grokPresetEntries}
|
||||
presetCategoryLabels={presetCategoryLabels}
|
||||
onPresetChange={handlePresetChange}
|
||||
category={category}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BasicFormFields form={form} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="grokbuild-profile">
|
||||
{t("grokBuild.profile", { defaultValue: "客户端模型档位" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="grokbuild-profile"
|
||||
value={profile}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setProfile(value);
|
||||
syncStructuredConfig({ model: value });
|
||||
}}
|
||||
placeholder="grok-4.5"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="grokbuild-api-backend">
|
||||
{t("grokBuild.apiBackend", { defaultValue: "API Backend" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="grokbuild-api-backend"
|
||||
value={apiBackend}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setApiBackend(value);
|
||||
syncStructuredConfig({ apiBackend: value });
|
||||
}}
|
||||
placeholder="responses"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="grokbuild-context-window">
|
||||
{t("grokBuild.contextWindow", { defaultValue: "上下文窗口" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="grokbuild-context-window"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={contextWindow}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setContextWindow(value);
|
||||
syncStructuredConfig({
|
||||
contextWindow: Number.parseInt(value, 10),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<CodexFormFields
|
||||
appId="grokbuild"
|
||||
providerId={providerId}
|
||||
codexApiKey={apiKey}
|
||||
onApiKeyChange={(value) => {
|
||||
setApiKey(value);
|
||||
syncStructuredConfig({ apiKey: value });
|
||||
}}
|
||||
category={category}
|
||||
shouldShowApiKeyLink={Boolean(websiteUrl)}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
shouldShowSpeedTest
|
||||
codexBaseUrl={baseUrl}
|
||||
onBaseUrlChange={(value) => {
|
||||
setBaseUrl(value);
|
||||
syncStructuredConfig({ baseUrl: value });
|
||||
}}
|
||||
isFullUrl={isFullUrl}
|
||||
onFullUrlChange={setIsFullUrl}
|
||||
isEndpointModalOpen={isEndpointModalOpen}
|
||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
codexModel={upstreamModel}
|
||||
onModelChange={(value) => {
|
||||
setUpstreamModel(value);
|
||||
syncStructuredConfig({ upstreamModel: value });
|
||||
}}
|
||||
apiFormat={apiFormat}
|
||||
onApiFormatChange={(value) => {
|
||||
const backend = grokApiBackendFromApiFormat(value);
|
||||
setApiFormat(value);
|
||||
setApiBackend(backend);
|
||||
syncStructuredConfig({ apiBackend: backend });
|
||||
}}
|
||||
anthropicAuthField={anthropicAuthField}
|
||||
onAnthropicAuthFieldChange={setAnthropicAuthField}
|
||||
impersonateClaudeCode={impersonateClaudeCode}
|
||||
onImpersonateClaudeCodeChange={setImpersonateClaudeCode}
|
||||
maxOutputTokens={maxOutputTokens}
|
||||
onMaxOutputTokensChange={setMaxOutputTokens}
|
||||
codexChatReasoning={codexChatReasoning}
|
||||
onCodexChatReasoningChange={setCodexChatReasoning}
|
||||
promptCacheRouting={promptCacheRouting}
|
||||
onPromptCacheRoutingChange={setPromptCacheRouting}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
customUserAgent={customUserAgent}
|
||||
onCustomUserAgentChange={setCustomUserAgent}
|
||||
localProxyHeadersOverride={headersOverride}
|
||||
onLocalProxyHeadersOverrideChange={setHeadersOverride}
|
||||
localProxyBodyOverride={bodyOverride}
|
||||
onLocalProxyBodyOverrideChange={setBodyOverride}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="grokbuild-config-toml">
|
||||
{t("grokBuild.rawConfig", { defaultValue: "config.toml" })}
|
||||
</FormLabel>
|
||||
<JsonEditor
|
||||
value={rawConfig}
|
||||
onChange={handleRawConfigChange}
|
||||
placeholder=""
|
||||
darkMode={isDarkMode}
|
||||
rows={12}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
{rawConfigError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("grokBuild.invalidToml", {
|
||||
error: rawConfigError,
|
||||
defaultValue: `Invalid config.toml: ${rawConfigError}`,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={() => (
|
||||
<FormItem className="hidden">
|
||||
<FormControl>
|
||||
<Input type="hidden" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{showButtons && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" type="button" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||
import { ClaudeDesktopProviderForm } from "./ClaudeDesktopProviderForm";
|
||||
import { GrokBuildProviderForm } from "./GrokBuildProviderForm";
|
||||
import { CodexFormFields } from "./CodexFormFields";
|
||||
import { GeminiFormFields } from "./GeminiFormFields";
|
||||
import { OmoFormFields } from "./OmoFormFields";
|
||||
@@ -243,6 +244,9 @@ export function ProviderForm(props: ProviderFormProps) {
|
||||
if (props.appId === "claude-desktop") {
|
||||
return <ClaudeDesktopProviderForm {...props} />;
|
||||
}
|
||||
if (props.appId === "grokbuild") {
|
||||
return <GrokBuildProviderForm {...props} />;
|
||||
}
|
||||
|
||||
return <ProviderFormFull {...props} />;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ export function ClaudeDesktopRouteToggle({
|
||||
|
||||
const isBusy = isStarting || isStoppingServer;
|
||||
const otherTakeoverActive = Boolean(
|
||||
takeoverStatus?.claude || takeoverStatus?.codex || takeoverStatus?.gemini,
|
||||
takeoverStatus?.claude ||
|
||||
takeoverStatus?.codex ||
|
||||
takeoverStatus?.gemini ||
|
||||
takeoverStatus?.grokbuild,
|
||||
);
|
||||
const routeAddress = status?.address ?? "127.0.0.1";
|
||||
const routePort = status?.port ?? 15721;
|
||||
|
||||
@@ -38,7 +38,9 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
? "Claude"
|
||||
: activeApp === "codex"
|
||||
? "Codex"
|
||||
: "Gemini";
|
||||
: activeApp === "grokbuild"
|
||||
? "Grok Build"
|
||||
: "Gemini";
|
||||
|
||||
const tooltipText = !takeoverEnabled
|
||||
? t("failover.tooltip.takeoverRequired", {
|
||||
|
||||
@@ -72,6 +72,7 @@ export function ProxyPanel({
|
||||
const { data: claudeQueue = [] } = useFailoverQueue("claude");
|
||||
const { data: codexQueue = [] } = useFailoverQueue("codex");
|
||||
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
|
||||
const { data: grokQueue = [] } = useFailoverQueue("grokbuild");
|
||||
|
||||
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
|
||||
try {
|
||||
@@ -272,30 +273,32 @@ export function ProxyPanel({
|
||||
defaultValue: "应用接管",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{(["claude", "codex", "gemini"] as const).map((appType) => {
|
||||
const isEnabled =
|
||||
takeoverStatus?.[
|
||||
appType as keyof typeof takeoverStatus
|
||||
] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={appType}
|
||||
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{appType}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleTakeoverChange(appType, checked)
|
||||
}
|
||||
disabled={setTakeoverForApp.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{(["claude", "codex", "gemini", "grokbuild"] as const).map(
|
||||
(appType) => {
|
||||
const isEnabled =
|
||||
takeoverStatus?.[
|
||||
appType as keyof typeof takeoverStatus
|
||||
] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={appType}
|
||||
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{appType === "grokbuild" ? "Grok Build" : appType}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleTakeoverChange(appType, checked)
|
||||
}
|
||||
disabled={setTakeoverForApp.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.takeover.hint", {
|
||||
@@ -415,7 +418,8 @@ export function ProxyPanel({
|
||||
{/* [6] Provider queues */}
|
||||
{(claudeQueue.length > 0 ||
|
||||
codexQueue.length > 0 ||
|
||||
geminiQueue.length > 0) && (
|
||||
geminiQueue.length > 0 ||
|
||||
grokQueue.length > 0) && (
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -459,6 +463,18 @@ export function ProxyPanel({
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
|
||||
{grokQueue.length > 0 && (
|
||||
<ProviderQueueGroup
|
||||
appType="grokbuild"
|
||||
appLabel="Grok Build"
|
||||
targets={grokQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,9 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
||||
? "Codex"
|
||||
: activeApp === "gemini"
|
||||
? "Gemini"
|
||||
: "OpenCode";
|
||||
: activeApp === "grokbuild"
|
||||
? "Grok Build"
|
||||
: "OpenCode";
|
||||
|
||||
const tooltipText = takeoverEnabled
|
||||
? isRunning
|
||||
|
||||
@@ -83,6 +83,7 @@ const SESSION_GROUP_EXPANSION_STORAGE_KEY =
|
||||
type ProviderFilter =
|
||||
| "all"
|
||||
| "codex"
|
||||
| "grokbuild"
|
||||
| "claude"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
@@ -1077,6 +1078,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="grokbuild">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="grok"
|
||||
name="grokbuild"
|
||||
size={14}
|
||||
/>
|
||||
<span>Grok Build</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
|
||||
@@ -121,6 +121,7 @@ export const getProviderLabel = (
|
||||
// 根据 providerId 获取对应的图标名称
|
||||
export const getProviderIconName = (providerId: string) => {
|
||||
if (providerId === "codex") return "openai";
|
||||
if (providerId === "grokbuild") return "grok";
|
||||
if (providerId === "claude") return "claude";
|
||||
if (providerId === "opencode") return "opencode";
|
||||
if (providerId === "openclaw") return "openclaw";
|
||||
|
||||
@@ -63,6 +63,7 @@ const TOOL_NAMES = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grok",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
@@ -130,6 +131,8 @@ ${posixScriptInstallCommand("https://claude.ai/install.sh")} || npm i -g @anthro
|
||||
npm i -g @openai/codex@latest
|
||||
# Gemini CLI
|
||||
npm i -g @google/gemini-cli@latest
|
||||
# Grok Build
|
||||
npm i -g @xai-official/grok@latest
|
||||
# OpenCode
|
||||
${posixScriptInstallCommand("https://opencode.ai/install")} || npm i -g opencode-ai@latest
|
||||
# OpenClaw
|
||||
@@ -143,6 +146,8 @@ npm i -g @anthropic-ai/claude-code@latest
|
||||
npm i -g @openai/codex@latest
|
||||
# Gemini CLI
|
||||
npm i -g @google/gemini-cli@latest
|
||||
# Grok Build
|
||||
npm i -g @xai-official/grok@latest
|
||||
# OpenCode
|
||||
npm i -g opencode-ai@latest
|
||||
# OpenClaw
|
||||
@@ -158,6 +163,7 @@ const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini CLI",
|
||||
grok: "Grok Build",
|
||||
opencode: "OpenCode",
|
||||
openclaw: "OpenClaw",
|
||||
hermes: "Hermes",
|
||||
@@ -173,6 +179,7 @@ const TOOL_APP_IDS: Record<ToolName, AppId> = {
|
||||
claude: "claude",
|
||||
codex: "codex",
|
||||
gemini: "gemini",
|
||||
grok: "grokbuild",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
hermes: "hermes",
|
||||
|
||||
@@ -26,6 +26,7 @@ const APP_CONFIG: Array<{
|
||||
},
|
||||
{ id: "codex", icon: "openai", nameKey: "apps.codex" },
|
||||
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
|
||||
{ id: "grokbuild", icon: "grok", nameKey: "apps.grokbuild" },
|
||||
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
|
||||
{ id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" },
|
||||
{ id: "hermes", icon: "hermes", nameKey: "apps.hermes" },
|
||||
@@ -42,6 +43,7 @@ export function AppVisibilitySettings({
|
||||
"claude-desktop": true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: true,
|
||||
@@ -73,7 +75,7 @@ export function AppVisibilitySettings({
|
||||
{t("settings.appVisibility.description")}
|
||||
</p>
|
||||
</header>
|
||||
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
|
||||
<div className="flex flex-wrap gap-1 rounded-md border border-border-default bg-background p-1">
|
||||
{APP_CONFIG.map((app) => {
|
||||
const isVisible = visibleApps[app.id];
|
||||
// Disable button if this is the last visible app
|
||||
|
||||
@@ -17,6 +17,7 @@ interface DirectorySettingsProps {
|
||||
claudeDir?: string;
|
||||
codexDir?: string;
|
||||
geminiDir?: string;
|
||||
grokDir?: string;
|
||||
opencodeDir?: string;
|
||||
openclawDir?: string;
|
||||
hermesDir?: string;
|
||||
@@ -34,6 +35,7 @@ export function DirectorySettings({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
geminiDir,
|
||||
grokDir,
|
||||
opencodeDir,
|
||||
openclawDir,
|
||||
hermesDir,
|
||||
@@ -126,6 +128,17 @@ export function DirectorySettings({
|
||||
onReset={() => onResetDirectory("gemini")}
|
||||
/>
|
||||
|
||||
<DirectoryInput
|
||||
label={t("settings.grokConfigDir")}
|
||||
description={undefined}
|
||||
value={grokDir}
|
||||
resolvedValue={resolvedDirs.grokbuild}
|
||||
placeholder={t("settings.browsePlaceholderGrok")}
|
||||
onChange={(val) => onDirectoryChange("grokbuild", val)}
|
||||
onBrowse={() => onBrowseDirectory("grokbuild")}
|
||||
onReset={() => onResetDirectory("grokbuild")}
|
||||
/>
|
||||
|
||||
<DirectoryInput
|
||||
label={t("settings.opencodeConfigDir")}
|
||||
description={undefined}
|
||||
|
||||
@@ -352,6 +352,7 @@ export function SettingsPage({
|
||||
claudeDir={settings.claudeConfigDir}
|
||||
codexDir={settings.codexConfigDir}
|
||||
geminiDir={settings.geminiConfigDir}
|
||||
grokDir={settings.grokConfigDir}
|
||||
opencodeDir={settings.opencodeConfigDir}
|
||||
openclawDir={settings.openclawConfigDir}
|
||||
hermesDir={settings.hermesConfigDir}
|
||||
|
||||
@@ -119,6 +119,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
"claude-desktop": 0,
|
||||
codex: 0,
|
||||
gemini: 0,
|
||||
grokbuild: 0,
|
||||
opencode: 0,
|
||||
openclaw: 0,
|
||||
hermes: 0,
|
||||
@@ -746,6 +747,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
claude: skill.foundIn.includes("claude"),
|
||||
codex: skill.foundIn.includes("codex"),
|
||||
gemini: skill.foundIn.includes("gemini"),
|
||||
grokbuild: skill.foundIn.includes("grokbuild"),
|
||||
opencode: skill.foundIn.includes("opencode"),
|
||||
openclaw: false,
|
||||
hermes: skill.foundIn.includes("hermes"),
|
||||
@@ -772,6 +774,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
@@ -815,6 +818,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
@@ -828,6 +832,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
|
||||
@@ -33,7 +33,7 @@ import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
|
||||
const PRICING_APPS = ["claude", "codex", "gemini"] as const;
|
||||
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild"] as const;
|
||||
type PricingApp = (typeof PRICING_APPS)[number];
|
||||
type PricingModelSource = "request" | "response";
|
||||
|
||||
@@ -57,6 +57,7 @@ export function PricingConfigPanel() {
|
||||
claude: { multiplier: "1", source: "response" },
|
||||
codex: { multiplier: "1", source: "response" },
|
||||
gemini: { multiplier: "1", source: "response" },
|
||||
grokbuild: { multiplier: "1", source: "response" },
|
||||
});
|
||||
const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(
|
||||
null,
|
||||
@@ -102,6 +103,7 @@ export function PricingConfigPanel() {
|
||||
claude: { multiplier: "1", source: "response" },
|
||||
codex: { multiplier: "1", source: "response" },
|
||||
gemini: { multiplier: "1", source: "response" },
|
||||
grokbuild: { multiplier: "1", source: "response" },
|
||||
};
|
||||
for (const result of results) {
|
||||
newState[result.app] = {
|
||||
|
||||
@@ -63,6 +63,7 @@ const APP_FILTER_ICON: Record<AppType, string> = {
|
||||
claude: "claude",
|
||||
codex: "openai",
|
||||
gemini: "gemini",
|
||||
grokbuild: "grok",
|
||||
opencode: "opencode",
|
||||
};
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ const TITLE_THEMES: Record<AppType | "all", TitleTheme> = {
|
||||
accent: "text-sky-600 dark:text-sky-400",
|
||||
iconBg: "bg-sky-500/10",
|
||||
},
|
||||
grokbuild: {
|
||||
accent: "text-rose-600 dark:text-rose-400",
|
||||
iconBg: "bg-rose-500/10",
|
||||
},
|
||||
opencode: {
|
||||
accent: "text-purple-600 dark:text-purple-400",
|
||||
iconBg: "bg-purple-500/10",
|
||||
|
||||
@@ -20,6 +20,7 @@ export const APP_IDS: AppId[] = [
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
@@ -30,6 +31,7 @@ export const SKILLS_APP_IDS: AppId[] = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
"hermes",
|
||||
];
|
||||
@@ -70,6 +72,21 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
||||
badgeClass:
|
||||
"bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5",
|
||||
},
|
||||
grokbuild: {
|
||||
label: "Grok Build",
|
||||
icon: (
|
||||
<ProviderIcon
|
||||
icon="grok"
|
||||
name="Grok Build"
|
||||
size={14}
|
||||
showFallback={false}
|
||||
/>
|
||||
),
|
||||
activeClass:
|
||||
"bg-cyan-500/10 ring-1 ring-cyan-500/20 hover:bg-cyan-500/20 text-cyan-700 dark:text-cyan-300",
|
||||
badgeClass:
|
||||
"bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 hover:bg-cyan-500/20 border-0 gap-1.5",
|
||||
},
|
||||
opencode: {
|
||||
label: "OpenCode",
|
||||
icon: (
|
||||
|
||||
@@ -10,6 +10,7 @@ type AppDirectoryKey =
|
||||
| "claude"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes";
|
||||
@@ -20,6 +21,7 @@ export interface ResolvedDirectories {
|
||||
claude: string;
|
||||
codex: string;
|
||||
gemini: string;
|
||||
grokbuild: string;
|
||||
opencode: string;
|
||||
openclaw: string;
|
||||
hermes: string;
|
||||
@@ -33,6 +35,7 @@ const APP_DIRECTORY_META: Record<
|
||||
claude: { key: "claude", defaultFolder: ".claude" },
|
||||
codex: { key: "codex", defaultFolder: ".codex" },
|
||||
gemini: { key: "gemini", defaultFolder: ".gemini" },
|
||||
grokbuild: { key: "grokbuild", defaultFolder: ".grok" },
|
||||
opencode: { key: "opencode", defaultFolder: ".config/opencode" },
|
||||
openclaw: { key: "openclaw", defaultFolder: ".openclaw" },
|
||||
hermes: { key: "hermes", defaultFolder: ".hermes" },
|
||||
@@ -45,6 +48,7 @@ const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record<
|
||||
claude: "claudeConfigDir",
|
||||
codex: "codexConfigDir",
|
||||
gemini: "geminiConfigDir",
|
||||
grokbuild: "grokConfigDir",
|
||||
opencode: "opencodeConfigDir",
|
||||
openclaw: "openclawConfigDir",
|
||||
hermes: "hermesConfigDir",
|
||||
@@ -130,6 +134,7 @@ export function useDirectorySettings({
|
||||
claude: "",
|
||||
codex: "",
|
||||
gemini: "",
|
||||
grokbuild: "",
|
||||
opencode: "",
|
||||
openclaw: "",
|
||||
hermes: "",
|
||||
@@ -141,6 +146,7 @@ export function useDirectorySettings({
|
||||
claude: "",
|
||||
codex: "",
|
||||
gemini: "",
|
||||
grokbuild: "",
|
||||
opencode: "",
|
||||
openclaw: "",
|
||||
hermes: "",
|
||||
@@ -159,6 +165,7 @@ export function useDirectorySettings({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
geminiDir,
|
||||
grokDir,
|
||||
opencodeDir,
|
||||
openclawDir,
|
||||
hermesDir,
|
||||
@@ -166,6 +173,7 @@ export function useDirectorySettings({
|
||||
defaultClaudeDir,
|
||||
defaultCodexDir,
|
||||
defaultGeminiDir,
|
||||
defaultGrokDir,
|
||||
defaultOpencodeDir,
|
||||
defaultOpenclawDir,
|
||||
defaultHermesDir,
|
||||
@@ -174,6 +182,7 @@ export function useDirectorySettings({
|
||||
settingsApi.getConfigDir("claude"),
|
||||
settingsApi.getConfigDir("codex"),
|
||||
settingsApi.getConfigDir("gemini"),
|
||||
settingsApi.getConfigDir("grokbuild"),
|
||||
settingsApi.getConfigDir("opencode"),
|
||||
settingsApi.getConfigDir("openclaw"),
|
||||
settingsApi.getConfigDir("hermes"),
|
||||
@@ -181,6 +190,7 @@ export function useDirectorySettings({
|
||||
computeDefaultConfigDir("claude"),
|
||||
computeDefaultConfigDir("codex"),
|
||||
computeDefaultConfigDir("gemini"),
|
||||
computeDefaultConfigDir("grokbuild"),
|
||||
computeDefaultConfigDir("opencode"),
|
||||
computeDefaultConfigDir("openclaw"),
|
||||
computeDefaultConfigDir("hermes"),
|
||||
@@ -195,6 +205,7 @@ export function useDirectorySettings({
|
||||
claude: defaultClaudeDir ?? "",
|
||||
codex: defaultCodexDir ?? "",
|
||||
gemini: defaultGeminiDir ?? "",
|
||||
grokbuild: defaultGrokDir ?? "",
|
||||
opencode: defaultOpencodeDir ?? "",
|
||||
openclaw: defaultOpenclawDir ?? "",
|
||||
hermes: defaultHermesDir ?? "",
|
||||
@@ -208,6 +219,7 @@ export function useDirectorySettings({
|
||||
claude: claudeDir || defaultsRef.current.claude,
|
||||
codex: codexDir || defaultsRef.current.codex,
|
||||
gemini: geminiDir || defaultsRef.current.gemini,
|
||||
grokbuild: grokDir || defaultsRef.current.grokbuild,
|
||||
opencode: opencodeDir || defaultsRef.current.opencode,
|
||||
openclaw: openclawDir || defaultsRef.current.openclaw,
|
||||
hermes: hermesDir || defaultsRef.current.hermes,
|
||||
@@ -349,6 +361,7 @@ export function useDirectorySettings({
|
||||
claude: overrides?.claude ?? defaultsRef.current.claude,
|
||||
codex: overrides?.codex ?? defaultsRef.current.codex,
|
||||
gemini: overrides?.gemini ?? defaultsRef.current.gemini,
|
||||
grokbuild: overrides?.grokbuild ?? defaultsRef.current.grokbuild,
|
||||
opencode: overrides?.opencode ?? defaultsRef.current.opencode,
|
||||
openclaw: overrides?.openclaw ?? defaultsRef.current.openclaw,
|
||||
hermes: overrides?.hermes ?? defaultsRef.current.hermes,
|
||||
|
||||
@@ -159,7 +159,7 @@ export function useProviderActions(
|
||||
activeApp === "claude" &&
|
||||
provider.meta?.providerType === "github_copilot";
|
||||
const isCodexChatFormat =
|
||||
activeApp === "codex" &&
|
||||
(activeApp === "codex" || activeApp === "grokbuild") &&
|
||||
(provider.meta?.apiFormat === "openai_chat" ||
|
||||
(typeof (provider.settingsConfig as Record<string, any>)?.config ===
|
||||
"string" &&
|
||||
@@ -169,7 +169,7 @@ export function useProviderActions(
|
||||
),
|
||||
)));
|
||||
const isCodexAnthropicFormat =
|
||||
activeApp === "codex" &&
|
||||
(activeApp === "codex" || activeApp === "grokbuild") &&
|
||||
(provider.meta?.apiFormat === "anthropic" ||
|
||||
(typeof (provider.settingsConfig as Record<string, any>)?.config ===
|
||||
"string" &&
|
||||
@@ -220,7 +220,9 @@ export function useProviderActions(
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.isFullUrl &&
|
||||
(activeApp === "claude" || activeApp === "codex")
|
||||
(activeApp === "claude" ||
|
||||
activeApp === "codex" ||
|
||||
activeApp === "grokbuild")
|
||||
) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonFullUrl", {
|
||||
defaultValue: "开启了完整 URL 连接模式",
|
||||
@@ -281,6 +283,9 @@ export function useProviderActions(
|
||||
if (activeApp === "codex") {
|
||||
messageKey = "notifications.codexRestartRequired";
|
||||
defaultMessage = "切换成功,请重启客户端以生效";
|
||||
} else if (activeApp === "grokbuild") {
|
||||
messageKey = "notifications.grokBuildRestartRequired";
|
||||
defaultMessage = "切换成功,请重启 Grok Build 以生效";
|
||||
} else if (activeApp === "claude-desktop") {
|
||||
if (provider.meta?.claudeDesktopMode === "proxy") {
|
||||
messageKey = "notifications.claudeDesktopProxyRestartRequired";
|
||||
|
||||
@@ -132,7 +132,9 @@ export function useProxyStatus() {
|
||||
? "Codex"
|
||||
: variables.appType === "gemini"
|
||||
? "Gemini"
|
||||
: "OpenCode";
|
||||
: variables.appType === "grokbuild"
|
||||
? "Grok Build"
|
||||
: "OpenCode";
|
||||
|
||||
toast.success(
|
||||
variables.enabled
|
||||
@@ -215,6 +217,7 @@ export function useProxyStatus() {
|
||||
takeoverStatus?.claude ||
|
||||
takeoverStatus?.codex ||
|
||||
takeoverStatus?.gemini ||
|
||||
takeoverStatus?.grokbuild ||
|
||||
false,
|
||||
|
||||
// 启动/停止(总开关)
|
||||
|
||||
@@ -110,6 +110,7 @@ export function useSettings(): UseSettingsResult {
|
||||
claude: sanitizeDir(data?.claudeConfigDir),
|
||||
codex: sanitizeDir(data?.codexConfigDir),
|
||||
gemini: sanitizeDir(data?.geminiConfigDir),
|
||||
grokbuild: sanitizeDir(data?.grokConfigDir),
|
||||
opencode: sanitizeDir(data?.opencodeConfigDir),
|
||||
openclaw: sanitizeDir(data?.openclawConfigDir),
|
||||
hermes: sanitizeDir(data?.hermesConfigDir),
|
||||
@@ -187,6 +188,7 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
const sanitizedGrokDir = sanitizeDir(mergedSettings.grokConfigDir);
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
@@ -204,6 +206,7 @@ export function useSettings(): UseSettingsResult {
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
grokConfigDir: sanitizedGrokDir,
|
||||
opencodeConfigDir: sanitizedOpencodeDir,
|
||||
openclawConfigDir: sanitizedOpenclawDir,
|
||||
language: mergedSettings.language,
|
||||
@@ -318,6 +321,7 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
const sanitizedGrokDir = sanitizeDir(mergedSettings.grokConfigDir);
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
@@ -328,6 +332,7 @@ export function useSettings(): UseSettingsResult {
|
||||
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
|
||||
const previousGrokDir = sanitizeDir(data?.grokConfigDir);
|
||||
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
|
||||
const previousOpenclawDir = sanitizeDir(data?.openclawConfigDir);
|
||||
const {
|
||||
@@ -341,6 +346,7 @@ export function useSettings(): UseSettingsResult {
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
grokConfigDir: sanitizedGrokDir,
|
||||
opencodeConfigDir: sanitizedOpencodeDir,
|
||||
openclawConfigDir: sanitizedOpenclawDir,
|
||||
language: mergedSettings.language,
|
||||
@@ -427,6 +433,7 @@ export function useSettings(): UseSettingsResult {
|
||||
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
|
||||
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
|
||||
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
|
||||
const grokDirChanged = sanitizedGrokDir !== previousGrokDir;
|
||||
const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir;
|
||||
const openclawDirChanged = sanitizedOpenclawDir !== previousOpenclawDir;
|
||||
if (
|
||||
@@ -434,6 +441,7 @@ export function useSettings(): UseSettingsResult {
|
||||
(claudeDirChanged ||
|
||||
codexDirChanged ||
|
||||
geminiDirChanged ||
|
||||
grokDirChanged ||
|
||||
opencodeDirChanged ||
|
||||
openclawDirChanged)
|
||||
) {
|
||||
|
||||
@@ -122,6 +122,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(data.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
|
||||
grokConfigDir: sanitizeDir(data.grokConfigDir),
|
||||
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
|
||||
openclawConfigDir: sanitizeDir(data.openclawConfigDir),
|
||||
language: normalizedLanguage,
|
||||
@@ -188,6 +189,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
|
||||
grokConfigDir: sanitizeDir(serverData.grokConfigDir),
|
||||
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
|
||||
openclawConfigDir: sanitizeDir(serverData.openclawConfigDir),
|
||||
language: normalizedLanguage,
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"providerDeleted": "Provider deleted successfully",
|
||||
"switchSuccess": "Switch successful!",
|
||||
"codexRestartRequired": "Switched successfully. Restart the client to apply changes.",
|
||||
"grokBuildRestartRequired": "Switched successfully. Restart Grok Build to apply changes.",
|
||||
"claudeDesktopRestartRequired": "Switched successfully. Restart Claude Desktop to apply changes.",
|
||||
"claudeDesktopProxyRestartRequired": "Switched successfully. Keep CC Switch running and restart Claude Desktop to apply changes.",
|
||||
"addToConfigSuccess": "Added to config",
|
||||
@@ -756,6 +757,8 @@
|
||||
"codexConfigDirDescription": "Override Codex configuration directory.",
|
||||
"geminiConfigDir": "Gemini Configuration Directory",
|
||||
"geminiConfigDirDescription": "Override Gemini configuration directory (.env).",
|
||||
"grokConfigDir": "Grok Build Configuration Directory",
|
||||
"grokConfigDirDescription": "Override Grok Build configuration directory (config.toml).",
|
||||
"opencodeConfigDir": "OpenCode Configuration Directory",
|
||||
"opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).",
|
||||
"openclawConfigDir": "OpenClaw Configuration Directory",
|
||||
@@ -765,6 +768,7 @@
|
||||
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
|
||||
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
|
||||
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
|
||||
"browsePlaceholderGrok": "e.g., /home/<your-username>/.grok",
|
||||
"browsePlaceholderOpencode": "e.g., /home/<your-username>/.config/opencode",
|
||||
"browsePlaceholderOpenclaw": "e.g., /home/<your-username>/.openclaw",
|
||||
"browsePlaceholderHermes": "e.g., /home/<your-username>/.hermes",
|
||||
@@ -875,13 +879,22 @@
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API backend",
|
||||
"contextWindow": "Context window",
|
||||
"contextWindowInvalid": "Context window must be a positive integer",
|
||||
"profile": "Client model profile",
|
||||
"rawConfig": "config.toml",
|
||||
"invalidToml": "Invalid config.toml: {{error}}"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "Session Manager",
|
||||
"subtitle": "Manage Claude Code, Codex, OpenCode, OpenClaw, Hermes and Gemini CLI sessions",
|
||||
"subtitle": "Manage Claude Code, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw and Hermes sessions",
|
||||
"searchPlaceholder": "Search by content, directory, or ID",
|
||||
"searchSessions": "Search sessions",
|
||||
"providerFilterAll": "All",
|
||||
@@ -1719,6 +1732,7 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"providerDeleted": "プロバイダーを削除しました",
|
||||
"switchSuccess": "切り替え成功!",
|
||||
"codexRestartRequired": "切り替えました。反映するにはクライアントを再起動してください",
|
||||
"grokBuildRestartRequired": "切り替えました。反映するには Grok Build を再起動してください",
|
||||
"claudeDesktopRestartRequired": "切り替えました。反映するには Claude Desktop を再起動してください",
|
||||
"claudeDesktopProxyRestartRequired": "切り替えました。CC Switch を起動したまま Claude Desktop を再起動してください",
|
||||
"addToConfigSuccess": "設定に追加しました",
|
||||
@@ -756,6 +757,8 @@
|
||||
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
|
||||
"geminiConfigDir": "Gemini 設定ディレクトリ",
|
||||
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
|
||||
"grokConfigDir": "Grok Build 設定ディレクトリ",
|
||||
"grokConfigDirDescription": "Grok Build の設定ディレクトリ(config.toml)を上書きします。",
|
||||
"opencodeConfigDir": "OpenCode 設定ディレクトリ",
|
||||
"opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。",
|
||||
"openclawConfigDir": "OpenClaw 設定ディレクトリ",
|
||||
@@ -765,6 +768,7 @@
|
||||
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
|
||||
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
|
||||
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
|
||||
"browsePlaceholderGrok": "例: /home/<your-username>/.grok",
|
||||
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
|
||||
"browsePlaceholderOpenclaw": "例: /home/<your-username>/.openclaw",
|
||||
"browsePlaceholderHermes": "例: /home/<your-username>/.hermes",
|
||||
@@ -875,13 +879,22 @@
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API Backend",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
"contextWindowInvalid": "コンテキストウィンドウは正の整数で指定してください",
|
||||
"profile": "クライアントモデルプロファイル",
|
||||
"rawConfig": "config.toml",
|
||||
"invalidToml": "config.toml の形式が不正です: {{error}}"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "セッション管理",
|
||||
"subtitle": "Claude Code / Codex / OpenCode / OpenClaw / Hermes / Gemini CLI のセッションを管理",
|
||||
"subtitle": "Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes のセッションを管理",
|
||||
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
@@ -1719,6 +1732,7 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"providerDeleted": "供應商刪除成功",
|
||||
"switchSuccess": "切換成功!",
|
||||
"codexRestartRequired": "切換成功,請重新啟動客戶端以套用",
|
||||
"grokBuildRestartRequired": "切換成功,請重新啟動 Grok Build 以套用",
|
||||
"claudeDesktopRestartRequired": "切換成功,重新啟動 Claude Desktop 後生效",
|
||||
"claudeDesktopProxyRestartRequired": "切換成功,請保持 CC Switch 執行,並重新啟動 Claude Desktop 後生效",
|
||||
"addToConfigSuccess": "已新增至設定",
|
||||
@@ -756,6 +757,8 @@
|
||||
"codexConfigDirDescription": "覆寫 Codex 設定目錄。",
|
||||
"geminiConfigDir": "Gemini 設定目錄",
|
||||
"geminiConfigDirDescription": "覆寫 Gemini 設定目錄 (.env)。",
|
||||
"grokConfigDir": "Grok Build 設定目錄",
|
||||
"grokConfigDirDescription": "覆寫 Grok Build 設定目錄 (config.toml)。",
|
||||
"opencodeConfigDir": "OpenCode 設定目錄",
|
||||
"opencodeConfigDirDescription": "覆寫 OpenCode 設定目錄 (opencode.json)。",
|
||||
"openclawConfigDir": "OpenClaw 設定目錄",
|
||||
@@ -765,6 +768,7 @@
|
||||
"browsePlaceholderClaude": "例如:/home/<您的帳號>/.claude",
|
||||
"browsePlaceholderCodex": "例如:/home/<您的帳號>/.codex",
|
||||
"browsePlaceholderGemini": "例如:/home/<您的帳號>/.gemini",
|
||||
"browsePlaceholderGrok": "例如:/home/<您的帳號>/.grok",
|
||||
"browsePlaceholderOpencode": "例如:/home/<您的帳號>/.config/opencode",
|
||||
"browsePlaceholderOpenclaw": "例如:/home/<您的帳號>/.openclaw",
|
||||
"browsePlaceholderHermes": "例如:/home/<您的帳號>/.hermes",
|
||||
@@ -846,13 +850,22 @@
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API Backend",
|
||||
"contextWindow": "上下文視窗",
|
||||
"contextWindowInvalid": "上下文視窗必須是正整數",
|
||||
"profile": "用戶端模型檔位",
|
||||
"rawConfig": "config.toml",
|
||||
"invalidToml": "config.toml 格式錯誤:{{error}}"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "工作階段管理",
|
||||
"subtitle": "管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄",
|
||||
"subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 與 Hermes 工作階段紀錄",
|
||||
"searchPlaceholder": "搜尋對話內容、目錄或 ID",
|
||||
"searchSessions": "搜尋工作階段",
|
||||
"providerFilterAll": "全部",
|
||||
@@ -1691,6 +1704,7 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"providerDeleted": "供应商删除成功",
|
||||
"switchSuccess": "切换成功!",
|
||||
"codexRestartRequired": "切换成功,请重启客户端以生效",
|
||||
"grokBuildRestartRequired": "切换成功,请重启 Grok Build 以生效",
|
||||
"claudeDesktopRestartRequired": "切换成功,重启 Claude Desktop 后生效",
|
||||
"claudeDesktopProxyRestartRequired": "切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效",
|
||||
"addToConfigSuccess": "已添加到配置",
|
||||
@@ -756,6 +757,8 @@
|
||||
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
|
||||
"geminiConfigDir": "Gemini 配置目录",
|
||||
"geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。",
|
||||
"grokConfigDir": "Grok Build 配置目录",
|
||||
"grokConfigDirDescription": "覆盖 Grok Build 配置目录 (config.toml)。",
|
||||
"opencodeConfigDir": "OpenCode 配置目录",
|
||||
"opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。",
|
||||
"openclawConfigDir": "OpenClaw 配置目录",
|
||||
@@ -765,6 +768,7 @@
|
||||
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
|
||||
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
|
||||
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
|
||||
"browsePlaceholderGrok": "例如:/home/<你的用户名>/.grok",
|
||||
"browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode",
|
||||
"browsePlaceholderOpenclaw": "例如:/home/<你的用户名>/.openclaw",
|
||||
"browsePlaceholderHermes": "例如:/home/<你的用户名>/.hermes",
|
||||
@@ -875,13 +879,22 @@
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API Backend",
|
||||
"contextWindow": "上下文窗口",
|
||||
"contextWindowInvalid": "上下文窗口必须是正整数",
|
||||
"profile": "客户端模型档位",
|
||||
"rawConfig": "config.toml",
|
||||
"invalidToml": "config.toml 格式错误:{{error}}"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "会话管理",
|
||||
"subtitle": "管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 与 Gemini CLI 会话记录",
|
||||
"subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 与 Hermes 会话记录",
|
||||
"searchPlaceholder": "搜索会话内容、目录或 ID",
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
@@ -1719,6 +1732,7 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AppType =
|
||||
| "claude-desktop"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes";
|
||||
@@ -17,6 +18,7 @@ export interface SkillApps {
|
||||
"claude-desktop"?: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
grokbuild?: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type AppId =
|
||||
| "claude-desktop"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes";
|
||||
|
||||
@@ -227,7 +227,9 @@ export function useSetAutoFailoverEnabled() {
|
||||
? "Claude"
|
||||
: variables.appType === "codex"
|
||||
? "Codex"
|
||||
: "Gemini";
|
||||
: variables.appType === "grokbuild"
|
||||
? "Grok Build"
|
||||
: "Gemini";
|
||||
|
||||
toast.success(
|
||||
variables.enabled
|
||||
|
||||
@@ -24,6 +24,7 @@ export const settingsSchema = z.object({
|
||||
claudeConfigDir: directorySchema.nullable().optional(),
|
||||
codexConfigDir: directorySchema.nullable().optional(),
|
||||
geminiConfigDir: directorySchema.nullable().optional(),
|
||||
grokConfigDir: directorySchema.nullable().optional(),
|
||||
opencodeConfigDir: directorySchema.nullable().optional(),
|
||||
openclawConfigDir: directorySchema.nullable().optional(),
|
||||
|
||||
|
||||
@@ -280,6 +280,7 @@ export interface VisibleApps {
|
||||
"claude-desktop": boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
grokbuild: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
@@ -394,6 +395,8 @@ export interface Settings {
|
||||
codexConfigDir?: string;
|
||||
// 覆盖 Gemini 配置目录(可选)
|
||||
geminiConfigDir?: string;
|
||||
// 覆盖 Grok Build 配置目录(可选)
|
||||
grokConfigDir?: string;
|
||||
// 覆盖 OpenCode 配置目录(可选)
|
||||
opencodeConfigDir?: string;
|
||||
// 覆盖 OpenClaw 配置目录(可选)
|
||||
@@ -488,6 +491,7 @@ export interface McpApps {
|
||||
"claude-desktop"?: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
grokbuild?: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface ProxyTakeoverStatus {
|
||||
"claude-desktop"?: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
grokbuild: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
|
||||
+3
-1
@@ -172,7 +172,7 @@ export interface UsageRangeSelection {
|
||||
* `opencode` / `openclaw` / `hermes` have no proxy handler at all — they
|
||||
* appear only as managed apps elsewhere.
|
||||
*/
|
||||
export type AppType = "claude" | "codex" | "gemini" | "opencode";
|
||||
export type AppType = "claude" | "codex" | "gemini" | "grokbuild" | "opencode";
|
||||
|
||||
export type AppTypeFilter = "all" | AppType;
|
||||
|
||||
@@ -180,6 +180,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
];
|
||||
|
||||
@@ -198,6 +199,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
|
||||
export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet<string> = new Set([
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
]);
|
||||
|
||||
/** Subset of request-log fields needed to derive cache-normalized input. */
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import {
|
||||
buildGrokBuildConfig,
|
||||
extractGrokBuildBaseUrl,
|
||||
parseGrokBuildConfig,
|
||||
updateGrokBuildConfig,
|
||||
validateGrokBuildConfig,
|
||||
} from "./grokBuildConfig";
|
||||
|
||||
describe("Grok Build config", () => {
|
||||
it("builds the expected provider TOML", () => {
|
||||
const config = buildGrokBuildConfig({
|
||||
model: "grok-4.5",
|
||||
baseUrl: "https://relay.example.com/v1",
|
||||
name: 'Relay "A"',
|
||||
apiKey: "secret",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 500000,
|
||||
});
|
||||
const parsed = parseToml(config) as any;
|
||||
|
||||
expect(parsed.models.default).toBe("grok-4.5");
|
||||
expect(parsed.model["grok-4.5"]).toEqual({
|
||||
model: "grok-4.5",
|
||||
base_url: "https://relay.example.com/v1",
|
||||
name: 'Relay "A"',
|
||||
api_key: "secret",
|
||||
api_backend: "responses",
|
||||
context_window: 500000,
|
||||
});
|
||||
expect(config).toContain('[model."grok-4.5"]');
|
||||
});
|
||||
|
||||
it("reads values back from a generated config", () => {
|
||||
const config = buildGrokBuildConfig({
|
||||
model: "custom-model",
|
||||
upstreamModel: "upstream-model",
|
||||
baseUrl: "https://api.example.com",
|
||||
name: "Custom",
|
||||
apiKey: "key",
|
||||
envKey: "",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 320000,
|
||||
});
|
||||
|
||||
expect(parseGrokBuildConfig(config)).toEqual({
|
||||
model: "custom-model",
|
||||
upstreamModel: "upstream-model",
|
||||
baseUrl: "https://api.example.com",
|
||||
name: "Custom",
|
||||
apiKey: "key",
|
||||
envKey: "",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 320000,
|
||||
});
|
||||
expect(extractGrokBuildBaseUrl(config)).toBe("https://api.example.com");
|
||||
});
|
||||
|
||||
it("accepts env_key credentials without adding an empty api_key", () => {
|
||||
const config = `[models]
|
||||
default = "env-profile"
|
||||
|
||||
[model."env-profile"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://api.example.com/v1"
|
||||
name = "Env Relay"
|
||||
env_key = "XAI_API_KEY"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
`;
|
||||
|
||||
expect(validateGrokBuildConfig(config)).toBeNull();
|
||||
expect(parseGrokBuildConfig(config).envKey).toBe("XAI_API_KEY");
|
||||
|
||||
const updated = updateGrokBuildConfig(config, {
|
||||
...parseGrokBuildConfig(config),
|
||||
baseUrl: "https://updated.example.com/v1",
|
||||
});
|
||||
const parsed = parseToml(updated) as any;
|
||||
expect(parsed.model["env-profile"].env_key).toBe("XAI_API_KEY");
|
||||
expect(parsed.model["env-profile"]).not.toHaveProperty("api_key");
|
||||
});
|
||||
|
||||
it("reports malformed, incomplete, and invalid-window configs", () => {
|
||||
expect(validateGrokBuildConfig("")).toBe("config.toml must not be empty");
|
||||
expect(validateGrokBuildConfig("[models")).not.toBeNull();
|
||||
expect(validateGrokBuildConfig('[models]\ndefault = "missing"\n')).toBe(
|
||||
"Missing [models] default model table",
|
||||
);
|
||||
|
||||
const missingCredentials = buildGrokBuildConfig({
|
||||
model: "grok-4.5",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
name: "Relay",
|
||||
apiKey: "",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 500000,
|
||||
});
|
||||
expect(validateGrokBuildConfig(missingCredentials)).toBe(
|
||||
"Missing api_key or env_key",
|
||||
);
|
||||
|
||||
const invalidWindow = missingCredentials.replace(
|
||||
"context_window = 500000",
|
||||
"context_window = 0",
|
||||
);
|
||||
expect(validateGrokBuildConfig(invalidWindow)).toBe(
|
||||
"Missing api_key or env_key",
|
||||
);
|
||||
expect(
|
||||
validateGrokBuildConfig(
|
||||
invalidWindow.replace(
|
||||
'name = "Relay"',
|
||||
'name = "Relay"\napi_key = "secret"',
|
||||
),
|
||||
),
|
||||
).toBe("context_window must be a positive integer");
|
||||
});
|
||||
|
||||
it("renames the selected profile without leaving the old table behind", () => {
|
||||
const original = buildGrokBuildConfig({
|
||||
model: "old-profile",
|
||||
upstreamModel: "grok-upstream",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
name: "Relay",
|
||||
apiKey: "secret",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 500000,
|
||||
});
|
||||
|
||||
const renamed = updateGrokBuildConfig(original, {
|
||||
...parseGrokBuildConfig(original),
|
||||
model: "new-profile",
|
||||
});
|
||||
const parsed = parseToml(renamed) as any;
|
||||
|
||||
expect(parsed.models.default).toBe("new-profile");
|
||||
expect(parsed.model["new-profile"].model).toBe("grok-upstream");
|
||||
expect(parsed.model).not.toHaveProperty("old-profile");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user