diff --git a/README.md b/README.md index ace7ef005..38094ad73 100644 --- a/README.md +++ b/README.md @@ -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 [![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](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
Which AI tools does CC Switch support? -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.
diff --git a/README_DE.md b/README_DE.md index 52d9327f8..057d3bce4 100644 --- a/README_DE.md +++ b/README_DE.md @@ -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 [![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](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
Welche KI-Werkzeuge unterstützt CC Switch? -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.
diff --git a/README_JA.md b/README_JA.md index af952a86e..4bf50198b 100644 --- a/README_JA.md +++ b/README_JA.md @@ -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 のオールインワン管理ツール [![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](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、スマートルーテ
CC Switch はどの AI ツールに対応していますか? -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 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
diff --git a/README_ZH.md b/README_ZH.md index 4f87c60ba..777ac74c8 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -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 的全方位管理工具 [![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](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
CC Switch 支持哪些 AI 工具? -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**。每个工具都有专属的供应商预设和配置管理。
diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 2af5333f6..74eac3f84 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -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() { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index db5b85015..d09377162 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -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 { } 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 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(), diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index aa6257c96..2095014e1 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -111,8 +111,8 @@ pub struct ToolVersion { wsl_distro: Option, } -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 尝试改动包管理器管理的安装。 /// ④ 其余支持官方自升级的工具 → ` 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 { let real_lower = real_target.to_ascii_lowercase(); @@ -2553,6 +2558,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option { "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" diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index e171d1e4b..05bc08ae4 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -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 { diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 07eb199a3..0bd744ba6 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -13,7 +13,7 @@ impl Database { pub fn get_all_mcp_servers(&self) -> Result, 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, ], diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 4c44f89cd..b2f37c126 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -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(()) } diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 9c94eb5ec..6c91225ca 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -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) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index d7ba00104..fb0f8969e 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -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(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 14bb0637c..a26ecdd32 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -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::, 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(()) + } } diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 84fe56837..2763f61e4 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -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 diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 822cd839d..e768e3494 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -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 { 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 { "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 { 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); + } +} diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index aca40fec6..ee854ff4f 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -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}'" ))); } } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 80cd8eb21..193f29ca5 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -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 diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 8ea4b2267..54681b444 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -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::().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"; diff --git a/src-tauri/src/grok_config.rs b/src-tauri/src/grok_config.rs new file mode 100644 index 000000000..687eabe2c --- /dev/null +++ b/src-tauri/src/grok_config.rs @@ -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, + pub env_key: Option, + 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 { + 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::().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.]", + "Grok Build configuration is missing [model.]", + ) + })?; + 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 { + let document = config_toml.parse::().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 { + extract_model_config(config_toml)?.api_key +} + +pub fn extract_base_url(config_toml: &str) -> Option { + Some(extract_model_config(config_toml)?.base_url) +} + +fn update_selected_model_string( + config_toml: &str, + field: &str, + value: &str, +) -> Result { + let mut document = config_toml + .parse::() + .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 { + 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 { + 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::() + .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 { + 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"), + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2b214c1a..688376532 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"]); + } } diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index e1971fb73..d55c2de4d 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result 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 Result { +pub(super) fn json_server_to_toml_table(spec: &Value) -> Result { use toml_edit::{Array, Item, Table}; let mut t = Table::new(); diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index 73c2fc840..7b9873037 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result bool { + crate::grok_config::get_grok_config_dir().exists() +} + +fn read_config_text() -> Result { + 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 { + 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 { + 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::>(), + )), + 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 { + 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::().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::() { + 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") + ); + } +} diff --git a/src-tauri/src/mcp/hermes.rs b/src-tauri/src/mcp/hermes.rs index 612a10973..24683cb41 100644 --- a/src-tauri/src/mcp/hermes.rs +++ b/src-tauri/src/mcp/hermes.rs @@ -315,6 +315,7 @@ pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result Result Result { 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 { 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 { 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"), }; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 4ce3c37d8..6c1c33a34 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -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")), diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 1df83e2d7..b0b2547a7 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -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); } diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index c73e57521..4c0b80881 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -758,6 +758,30 @@ pub async fn handle_chat_completions( pub async fn handle_responses( State(state): State, request: axum::extract::Request, +) -> Result { + handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await +} + +pub async fn handle_grokbuild_responses( + State(state): State, + request: axum::extract::Request, +) -> Result { + 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 { 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, request: axum::extract::Request, +) -> Result { + handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await +} + +pub async fn handle_grokbuild_responses_compact( + State(state): State, + request: axum::extract::Request, +) -> Result { + 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 { 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, diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index 3fc42eb20..b68a94df1 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -250,7 +250,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option { .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": "" })); diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index c70d36764..1aeec596f 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -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 { 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()), } } diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 6ef02bc81..1d9bdc37c 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -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` / diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index d309000f6..933db3c53 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -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, } diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs index db8def267..c25671310 100644 --- a/src-tauri/src/proxy/usage/calculator.rs +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -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 { diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index 072dd5ccf..549d81cf4 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -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(()) + } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index e37215117..7d465810a 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -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 diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index db330e5f8..b57591b82 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -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 { + 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 { // 创建临时 MultiAppConfig 用于导入 @@ -479,10 +516,11 @@ impl McpService { let mut total = 0; let mut failures: Vec = Vec::new(); - let results: [(&str, Result); 5] = [ + let results: [(&str, Result); 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)), ]; diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 1aa35a426..92b8b387c 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -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 { 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 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\"")); + } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 98398734e..010c697de 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -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)?; diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 263809609..bdbe7aa78 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -367,7 +367,7 @@ impl ProxyService { ) .map_err(|e| format!("构建 codex 有效配置失败: {e}"))?; if let Some(existing_live) = existing_live.as_ref() { - Self::preserve_codex_mcp_servers_from_existing_config( + Self::preserve_toml_mcp_servers_from_existing_config( &mut effective_settings, existing_live, )?; @@ -384,6 +384,29 @@ impl ProxyService { Ok(()) } + pub async fn sync_grok_live_from_provider_while_proxy_active( + &self, + provider: &Provider, + ) -> Result<(), String> { + let existing_live = self.read_grok_live().ok(); + let mut effective_settings = build_effective_settings_with_common_config( + self.db.as_ref(), + &AppType::GrokBuild, + provider, + ) + .map_err(|e| format!("构建 Grok Build 有效配置失败: {e}"))?; + if let Some(existing_live) = existing_live.as_ref() { + Self::preserve_toml_mcp_servers_from_existing_config( + &mut effective_settings, + existing_live, + )?; + } + let (proxy_url, _) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); + Self::apply_grok_takeover_fields(&mut effective_settings, &proxy_grok_base_url)?; + self.write_grok_live(&effective_settings) + } + fn get_current_provider_for_app(&self, app_type: &AppType) -> Result, String> { let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type) .map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))? @@ -396,6 +419,82 @@ impl ProxyService { .map_err(|e| format!("读取 {app_type:?} 当前供应商失败: {e}")) } + async fn refresh_active_target_from_current_provider(&self, app_type: &AppType) { + let Ok(Some(provider)) = self.get_current_provider_for_app(app_type) else { + return; + }; + if let Some(server) = self.server.read().await.as_ref() { + server + .set_active_target(app_type.as_str(), &provider.id, &provider.name) + .await; + } + } + + async fn rollback_hot_switch_preparation( + &self, + app_type: &AppType, + previous_backup: Option<&LiveBackup>, + previous_provider_id: Option<&str>, + should_sync_backup: bool, + live_taken_over: bool, + previous_live_before_direct_write: Option<&Value>, + ) { + if !should_sync_backup { + return; + } + + let rollback_result = match previous_backup { + Some(backup) => { + self.db + .save_live_backup(app_type.as_str(), &backup.original_config) + .await + } + None => self.db.delete_live_backup(app_type.as_str()).await, + }; + if let Err(error) = rollback_result { + log::error!("{} 热切换失败后恢复原备份失败: {error}", app_type.as_str()); + } + + if let Some(previous_live) = previous_live_before_direct_write { + if let Err(error) = self.write_codex_live_verbatim(previous_live) { + log::error!( + "{} 热切换失败后恢复直接写入前的 Live 配置失败: {error}", + app_type.as_str() + ); + } + return; + } + + let Some(previous_provider_id) = previous_provider_id else { + return; + }; + let Ok(Some(previous_provider)) = self + .db + .get_provider_by_id(previous_provider_id, app_type.as_str()) + else { + return; + }; + + let live_result = if matches!(app_type, AppType::Claude) { + self.sync_claude_live_from_provider_while_proxy_active(&previous_provider) + .await + } else if live_taken_over && matches!(app_type, AppType::Codex) { + self.sync_codex_live_from_provider_while_proxy_active(&previous_provider) + .await + } else if live_taken_over && matches!(app_type, AppType::GrokBuild) { + self.sync_grok_live_from_provider_while_proxy_active(&previous_provider) + .await + } else { + Ok(()) + }; + if let Err(error) = live_result { + log::error!( + "{} 热切换失败后恢复原 Live 配置失败: {error}", + app_type.as_str() + ); + } + } + fn require_current_provider_for_app(&self, app_type: &AppType) -> Result { self.get_current_provider_for_app(app_type)? .ok_or_else(|| format!("{app_type:?} 当前供应商不存在,无法接管 Live 配置")) @@ -604,6 +703,12 @@ impl ProxyService { .await .map(|c| c.enabled) .unwrap_or(false); + let grokbuild_enabled = self + .db + .get_proxy_config_for_app("grokbuild") + .await + .map(|c| c.enabled) + .unwrap_or(false); // OpenCode and OpenClaw don't support proxy features, always return false let opencode_enabled = false; let openclaw_enabled = false; @@ -612,6 +717,7 @@ impl ProxyService { claude: claude_enabled, codex: codex_enabled, gemini: gemini_enabled, + grokbuild: grokbuild_enabled, opencode: opencode_enabled, openclaw: openclaw_enabled, }) @@ -661,6 +767,7 @@ impl ProxyService { // 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后 // live 文件仍停留在普通供应商配置。 if has_backup && live_matches_current_proxy { + self.refresh_active_target_from_current_provider(&app).await; return Ok(()); } restore_existing_backup_before_takeover = has_backup; @@ -715,6 +822,8 @@ impl ProxyService { // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) let _ = self.db.set_live_takeover_active(true).await; + self.refresh_active_target_from_current_provider(&app).await; + // 8) Warn if the current provider is official (risk of account ban via proxy) if let Ok(Some(current_id)) = crate::settings::get_effective_current_provider(&self.db, &app) @@ -853,6 +962,7 @@ impl ProxyService { AppType::Claude => self.read_claude_live()?, AppType::Codex => self.read_codex_live()?, AppType::Gemini => self.read_gemini_live()?, + AppType::GrokBuild => self.read_grok_live()?, _ => return Err("该应用不支持代理功能".to_string()), }; @@ -1073,6 +1183,49 @@ impl ProxyService { } } } + AppType::GrokBuild => { + let provider_id = + crate::settings::get_effective_current_provider(&self.db, &AppType::GrokBuild) + .map_err(|e| format!("获取 Grok Build 当前供应商失败: {e}"))?; + + if let Some(provider_id) = provider_id { + if let Ok(Some(mut provider)) = + self.db.get_provider_by_id(&provider_id, "grokbuild") + { + let live_config_toml = live_config + .get("config") + .and_then(Value::as_str) + .unwrap_or_default(); + if let Some(token) = + crate::grok_config::extract_inline_api_key(live_config_toml) + { + if !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER { + if let Some(provider_config) = provider + .settings_config + .get("config") + .and_then(Value::as_str) + { + let updated = + crate::grok_config::update_api_key(provider_config, &token) + .map_err(|e| { + format!("更新 Grok Build API Key 失败: {e}") + })?; + provider.settings_config["config"] = json!(updated); + self.db + .update_provider_settings_config( + "grokbuild", + &provider_id, + &provider.settings_config, + ) + .map_err(|e| { + format!("同步 Grok Build Token 到数据库失败: {e}") + })?; + } + } + } + } + } + } _ => {} } @@ -1095,6 +1248,11 @@ impl ProxyService { .await?; } + if let Ok(live_config) = self.read_grok_live() { + self.sync_live_config_to_provider(&AppType::GrokBuild, &live_config) + .await?; + } + log::info!("Live 配置 Token 同步完成"); Ok(()) } @@ -1147,7 +1305,7 @@ impl ProxyService { .map_err(|e| format!("清除接管状态失败: {e}"))?; // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复) - for app_type in ["claude", "codex", "gemini"] { + for app_type in ["claude", "codex", "gemini", "grokbuild"] { if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await { if config.enabled { config.enabled = false; @@ -1258,6 +1416,20 @@ impl ProxyService { } } + // Grok Build + if let Ok(config) = self.read_grok_live() { + if Self::live_has_proxy_placeholder_for_app(&AppType::GrokBuild, &config) { + log::warn!("grokbuild Live 已被代理接管,不备份;下次 stop 会从 SSOT 重建 Live"); + } else { + let json_str = serde_json::to_string(&config) + .map_err(|e| format!("序列化 Grok Build 配置失败: {e}"))?; + self.db + .save_live_backup("grokbuild", &json_str) + .await + .map_err(|e| format!("备份 Grok Build 配置失败: {e}"))?; + } + } + log::info!("已备份所有应用的 Live 配置"); Ok(()) } @@ -1268,6 +1440,7 @@ impl ProxyService { AppType::Claude => ("claude", self.read_claude_live()?), AppType::Codex => ("codex", self.read_codex_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?), + AppType::GrokBuild => ("grokbuild", self.read_grok_live()?), _ => return Err("该应用不支持代理功能".to_string()), }; @@ -1329,6 +1502,21 @@ impl ProxyService { Ok((proxy_url, proxy_codex_base_url)) } + fn apply_grok_takeover_fields(config: &mut Value, proxy_base_url: &str) -> Result<(), String> { + let config_toml = config + .get("config") + .and_then(Value::as_str) + .ok_or_else(|| "Grok Build 配置缺少 config 字段".to_string())?; + let updated = crate::grok_config::apply_proxy_takeover( + config_toml, + proxy_base_url, + PROXY_TOKEN_PLACEHOLDER, + ) + .map_err(|e| format!("更新 Grok Build 接管配置失败: {e}"))?; + config["config"] = json!(updated); + Ok(()) + } + /// 接管各应用的 Live 配置(写入代理地址) /// /// 代理服务器的路由已经根据 API 端点自动区分应用类型: @@ -1339,6 +1527,7 @@ impl ProxyService { /// 因此不需要在 URL 中添加应用前缀。 async fn takeover_live_configs(&self) -> Result<(), String> { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) if let Ok(mut live_config) = self.read_claude_live() { @@ -1382,12 +1571,20 @@ impl ProxyService { log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } + // Grok Build: keep its own provider namespace while reusing Responses forwarding. + if let Ok(mut live_config) = self.read_grok_live() { + Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; + self.write_grok_live(&live_config)?; + log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}"); + } + Ok(()) } /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误) async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); match app_type { AppType::Claude => { @@ -1431,6 +1628,12 @@ impl ProxyService { self.write_gemini_live(&live_config)?; log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } + AppType::GrokBuild => { + let mut live_config = self.read_grok_live()?; + Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; + self.write_grok_live(&live_config)?; + log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}"); + } _ => return Err("该应用不支持代理功能".to_string()), } @@ -1440,6 +1643,7 @@ impl ProxyService { /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过) async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); match app_type { AppType::Claude => { @@ -1495,6 +1699,12 @@ impl ProxyService { let _ = self.write_gemini_live(&live_config); } } + AppType::GrokBuild => { + if let Ok(mut live_config) = self.read_grok_live() { + Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; + let _ = self.write_grok_live(&live_config); + } + } _ => {} } @@ -1527,6 +1737,14 @@ impl ProxyService { log::info!("Gemini Live 配置已恢复"); } } + AppType::GrokBuild => { + if let Ok(Some(backup)) = self.db.get_live_backup("grokbuild").await { + let config: Value = serde_json::from_str(&backup.original_config) + .map_err(|e| format!("解析 Grok Build 备份失败: {e}"))?; + self.write_grok_live(&config)?; + log::info!("Grok Build Live 配置已恢复"); + } + } _ => {} } @@ -1537,7 +1755,12 @@ impl ProxyService { async fn restore_live_configs(&self) -> Result<(), String> { let mut errors = Vec::new(); - for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app_type in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::GrokBuild, + ] { if let Err(e) = self .restore_live_config_for_app_with_fallback(&app_type) .await @@ -1626,6 +1849,7 @@ impl ProxyService { AppType::Claude => self.write_claude_live(config), AppType::Codex => self.write_codex_live(config), AppType::Gemini => self.write_gemini_live(config), + AppType::GrokBuild => self.write_grok_live(config), _ => Err("该应用不支持代理功能".to_string()), } } @@ -1644,6 +1868,10 @@ impl ProxyService { Ok(config) => Self::is_gemini_live_taken_over(&config), Err(_) => false, }, + AppType::GrokBuild => match self.read_grok_live() { + Ok(config) => Self::is_grok_live_taken_over(&config), + Err(_) => false, + }, _ => false, } } @@ -1694,6 +1922,7 @@ impl ProxyService { AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), + AppType::GrokBuild => self.cleanup_grok_takeover_placeholders_in_live(), _ => Ok(()), } } @@ -1753,6 +1982,7 @@ impl ProxyService { app_type: &AppType, ) -> Result { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); match app_type { AppType::Claude => { @@ -1785,6 +2015,19 @@ impl ProxyService { .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url)); Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches) } + AppType::GrokBuild => { + let config = self.read_grok_live()?; + let base_url_matches = + config + .get("config") + .and_then(Value::as_str) + .is_some_and(|config_toml| { + crate::grok_config::base_url_matches(config_toml, |url| { + Self::proxy_urls_match(url, &proxy_grok_base_url) + }) + }); + Ok(Self::is_grok_live_taken_over(&config) && base_url_matches) + } _ => Ok(false), } } @@ -1875,10 +2118,27 @@ impl ProxyService { Ok(()) } + fn cleanup_grok_takeover_placeholders_in_live(&self) -> Result<(), String> { + let config = self.read_grok_live()?; + let Some(config_toml) = config.get("config").and_then(Value::as_str) else { + return Ok(()); + }; + if !crate::grok_config::has_proxy_placeholder(config_toml, PROXY_TOKEN_PLACEHOLDER) { + return Ok(()); + } + + // A valid provider snapshot should normally restore before this fallback. + // Clearing the token prevents a stale local route from looking usable. + let updated = crate::grok_config::update_api_key(config_toml, "") + .map_err(|e| format!("清理 Grok Build 接管占位符失败: {e}"))?; + crate::config::write_text_file(&crate::grok_config::get_grok_config_path(), &updated) + .map_err(|e| format!("写入 Grok Build 配置失败: {e}")) + } + /// 检查是否处于 Live 接管模式 pub async fn is_takeover_active(&self) -> Result { let status = self.get_takeover_status().await?; - Ok(status.claude || status.codex || status.gemini) + Ok(status.claude || status.codex || status.gemini || status.grokbuild) } /// 从异常退出中恢复(启动时调用) @@ -1928,6 +2188,12 @@ impl ProxyService { } } + if let Ok(config) = self.read_grok_live() { + if Self::is_grok_live_taken_over(&config) { + return true; + } + } + false } @@ -1986,6 +2252,15 @@ impl ProxyService { env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) } + fn is_grok_live_taken_over(config: &Value) -> bool { + config + .get("config") + .and_then(Value::as_str) + .is_some_and(|config_toml| { + crate::grok_config::has_proxy_placeholder(config_toml, PROXY_TOKEN_PLACEHOLDER) + }) + } + /// 判断给定的 Live/备份配置是否已被代理接管(包含占位符) /// /// 用途:检测"备份里存的其实是代理配置"这种异常历史状态。 @@ -1997,6 +2272,7 @@ impl ProxyService { AppType::Claude => Self::is_claude_live_taken_over(config), AppType::Codex => Self::is_codex_live_taken_over(config), AppType::Gemini => Self::is_gemini_live_taken_over(config), + AppType::GrokBuild => Self::is_grok_live_taken_over(config), _ => false, } } @@ -2047,7 +2323,7 @@ impl ProxyService { existing_backup_value.or_else(|| self.read_codex_live().ok()); if let Some(existing_value) = existing_backup_value.as_ref() { - Self::preserve_codex_mcp_servers_from_existing_config( + Self::preserve_toml_mcp_servers_from_existing_config( &mut effective_settings, existing_value, )?; @@ -2067,11 +2343,33 @@ impl ProxyService { .map_err(|e| format!("注入统一会话路由失败: {e}"))?; } + if matches!(app_type_enum, AppType::GrokBuild) { + let existing_value = self + .db + .get_live_backup(app_type) + .await + .map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))? + .map(|backup| { + serde_json::from_str::(&backup.original_config) + .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}")) + }) + .transpose()? + .or_else(|| self.read_grok_live().ok()); + if let Some(existing_value) = existing_value.as_ref() { + Self::preserve_toml_mcp_servers_from_existing_config( + &mut effective_settings, + existing_value, + )?; + } + } + let backup_json = match app_type_enum { AppType::Claude => serde_json::to_string(&effective_settings) .map_err(|e| format!("序列化 Claude 配置失败: {e}"))?, AppType::Codex => serde_json::to_string(&effective_settings) .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?, + AppType::GrokBuild => serde_json::to_string(&effective_settings) + .map_err(|e| format!("序列化 Grok Build 配置失败: {e}"))?, AppType::Gemini => { // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。 let env_backup = if let Some(env) = effective_settings.get("env") { @@ -2130,11 +2428,11 @@ impl ProxyService { ); } - let logical_target_changed = + let previous_provider_id = crate::settings::get_effective_current_provider(&self.db, &app_type_enum) - .map_err(|e| format!("读取当前供应商失败: {e}"))? - .as_deref() - != Some(provider_id); + .map_err(|e| format!("读取当前供应商失败: {e}"))?; + let previous_local_provider_id = crate::settings::get_current_provider(&app_type_enum); + let logical_target_changed = previous_provider_id.as_deref() != Some(provider_id); let has_backup = self .db @@ -2145,46 +2443,119 @@ impl ProxyService { let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum); let should_sync_backup = has_backup || live_taken_over; - self.db - .set_current_provider(app_type_enum.as_str(), provider_id) - .map_err(|e| format!("更新当前供应商失败: {e}"))?; - crate::settings::set_current_provider(&app_type_enum, Some(provider_id)) - .map_err(|e| format!("更新本地当前供应商失败: {e}"))?; + // All fallible backup/live writes must finish before committing the logical + // current provider. Otherwise a failed hot switch leaves the UI pointing at + // the new provider while the proxy still serves the old one (and the next + // query may fall back to the first provider). + let previous_backup = if should_sync_backup { + self.db + .get_live_backup(app_type_enum.as_str()) + .await + .map_err(|e| format!("读取 {app_type} 原备份失败: {e}"))? + } else { + None + }; + let previous_live_before_direct_write = + if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) { + Some( + self.read_codex_live() + .map_err(|error| format!("读取 Codex 原 Live 配置失败: {error}"))?, + ) + } else { + None + }; - if should_sync_backup { - self.update_live_backup_from_provider_inner(app_type, &provider) - .await?; + let prepare_result: Result<(), String> = async { + if should_sync_backup { + self.update_live_backup_from_provider_inner(app_type, &provider) + .await?; - if matches!(app_type_enum, AppType::Claude) { - self.sync_claude_live_from_provider_while_proxy_active(&provider) - .await?; - } else if live_taken_over && matches!(app_type_enum, AppType::Codex) { - self.sync_codex_live_from_provider_while_proxy_active(&provider) - .await?; + if matches!(app_type_enum, AppType::Claude) { + self.sync_claude_live_from_provider_while_proxy_active(&provider) + .await?; + } else if live_taken_over && matches!(app_type_enum, AppType::Codex) { + self.sync_codex_live_from_provider_while_proxy_active(&provider) + .await?; + } else if live_taken_over && matches!(app_type_enum, AppType::GrokBuild) { + self.sync_grok_live_from_provider_while_proxy_active(&provider) + .await?; + } } + + if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) { + let effective_settings = build_effective_settings_with_common_config( + self.db.as_ref(), + &AppType::Codex, + &provider, + ) + .map_err(|e| format!("构建 Codex 有效配置失败: {e}"))?; + let auth = effective_settings + .get("auth") + .ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?; + let config_str = effective_settings.get("config").and_then(|v| v.as_str()); + let profile = + crate::proxy::providers::resolve_codex_catalog_tool_profile(&provider); + + crate::codex_config::write_codex_provider_live_with_catalog( + &effective_settings, + provider.category.as_deref(), + auth, + config_str, + profile, + ) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + } + + Ok(()) + } + .await; + + if let Err(error) = prepare_result { + self.rollback_hot_switch_preparation( + &app_type_enum, + previous_backup.as_ref(), + previous_provider_id.as_deref(), + should_sync_backup, + live_taken_over, + previous_live_before_direct_write.as_ref(), + ) + .await; + return Err(error); } - if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) { - let effective_settings = build_effective_settings_with_common_config( - self.db.as_ref(), - &AppType::Codex, - &provider, + if let Err(error) = crate::settings::set_current_provider(&app_type_enum, Some(provider_id)) + { + self.rollback_hot_switch_preparation( + &app_type_enum, + previous_backup.as_ref(), + previous_provider_id.as_deref(), + should_sync_backup, + live_taken_over, + previous_live_before_direct_write.as_ref(), ) - .map_err(|e| format!("构建 Codex 有效配置失败: {e}"))?; - let auth = effective_settings - .get("auth") - .ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?; - let config_str = effective_settings.get("config").and_then(|v| v.as_str()); - let profile = crate::proxy::providers::resolve_codex_catalog_tool_profile(&provider); - - crate::codex_config::write_codex_provider_live_with_catalog( - &effective_settings, - provider.category.as_deref(), - auth, - config_str, - profile, + .await; + return Err(format!("更新本地当前供应商失败: {error}")); + } + if let Err(error) = self + .db + .set_current_provider(app_type_enum.as_str(), provider_id) + { + if let Err(rollback_error) = crate::settings::set_current_provider( + &app_type_enum, + previous_local_provider_id.as_deref(), + ) { + log::error!("数据库切换失败后恢复本地当前供应商失败: {rollback_error}"); + } + self.rollback_hot_switch_preparation( + &app_type_enum, + previous_backup.as_ref(), + previous_provider_id.as_deref(), + should_sync_backup, + live_taken_over, + previous_live_before_direct_write.as_ref(), ) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + .await; + return Err(format!("更新当前供应商失败: {error}")); } if let Some(server) = self.server.read().await.as_ref() { @@ -2203,13 +2574,13 @@ impl ProxyService { self.switch_locks.lock_for_app(app_type).await } - fn preserve_codex_mcp_servers_from_existing_config( + fn preserve_toml_mcp_servers_from_existing_config( target_settings: &mut Value, existing_config: &Value, ) -> Result<(), String> { let target_obj = target_settings .as_object_mut() - .ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?; + .ok_or_else(|| "TOML 应用备份必须是 JSON 对象".to_string())?; let target_config = target_obj .get("config") @@ -2220,7 +2591,7 @@ impl ProxyService { } else { target_config .parse::() - .map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))? + .map_err(|e| format!("解析新的 config.toml 失败: {e}"))? }; let existing_config = existing_config @@ -2234,7 +2605,7 @@ impl ProxyService { let existing_doc = existing_config .parse::() - .map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?; + .map_err(|e| format!("解析现有 config.toml 备份失败: {e}"))?; if let Some(existing_mcp_servers) = existing_doc.get("mcp_servers") { match target_doc.get_mut("mcp_servers") { @@ -2250,7 +2621,7 @@ impl ProxyService { } } else { log::warn!( - "Codex config contains a non-table mcp_servers section; skipping MCP merge" + "config.toml contains a non-table mcp_servers section; skipping MCP merge" ); } } @@ -2625,6 +2996,16 @@ impl ProxyService { Ok(()) } + fn read_grok_live(&self) -> Result { + crate::grok_config::read_grok_live_settings() + .map_err(|e| format!("读取 Grok Build 配置失败: {e}")) + } + + fn write_grok_live(&self, config: &Value) -> Result<(), String> { + crate::grok_config::write_grok_live_settings(config) + .map_err(|e| format!("写入 Grok Build 配置失败: {e}")) + } + // ==================== 原有方法 ==================== /// 获取服务器状态 @@ -2721,6 +3102,11 @@ impl ProxyService { .await?; updated_any = true; } + if takeover.grokbuild { + self.takeover_live_config_best_effort(&AppType::GrokBuild) + .await?; + updated_any = true; + } if updated_any { log::info!("已同步更新 Live 配置中的代理地址"); @@ -6135,6 +6521,107 @@ requires_openai_auth = true ); } + #[tokio::test] + #[serial] + async fn codex_direct_live_write_rolls_back_when_provider_commit_fails() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let state = crate::store::AppState::new(db.clone()); + db.update_proxy_config(ProxyConfig { + listen_port: 0, + ..Default::default() + }) + .await + .expect("set test proxy config"); + state + .proxy_service + .start() + .await + .expect("start proxy server"); + + let provider_a = Provider::with_id( + "a".to_string(), + "Provider A".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "key-a" }, + "config": "model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"Provider A\"\nbase_url = \"https://a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n" + }), + None, + ); + let provider_b = Provider::with_id( + "b".to_string(), + "Provider B".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "key-b" }, + "config": "model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"Provider B\"\nbase_url = \"https://b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n" + }), + None, + ); + db.save_provider("codex", &provider_a) + .expect("save provider a"); + db.save_provider("codex", &provider_b) + .expect("save provider b"); + db.set_current_provider("codex", "a") + .expect("set current provider a"); + crate::settings::set_current_provider(&AppType::Codex, Some("a")) + .expect("set local current provider a"); + state + .proxy_service + .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a)) + .expect("seed direct live config"); + db.save_live_backup( + "codex", + &serde_json::to_string(&provider_a.settings_config).expect("serialize backup"), + ) + .await + .expect("seed restored backup"); + let original_live = state + .proxy_service + .read_codex_live() + .expect("read original live config"); + + { + let conn = db.conn.lock().expect("lock database"); + conn.execute_batch( + "CREATE TRIGGER reject_codex_current_update + BEFORE UPDATE OF is_current ON providers + WHEN NEW.app_type = 'codex' + BEGIN + SELECT RAISE(ABORT, 'forced current-provider commit failure'); + END;", + ) + .expect("install failure trigger"); + } + + let error = crate::services::provider::ProviderService::switch(&state, AppType::Codex, "b") + .expect_err("database commit should fail"); + state.proxy_service.stop().await.expect("stop proxy server"); + + assert!(error + .to_string() + .contains("forced current-provider commit failure")); + assert_eq!( + state + .proxy_service + .read_codex_live() + .expect("read rolled-back live config"), + original_live, + "commit failure must restore the exact direct Live snapshot" + ); + assert_eq!( + db.get_current_provider("codex") + .expect("read current provider") + .as_deref(), + Some("a") + ); + assert_eq!( + crate::settings::get_current_provider(&AppType::Codex).as_deref(), + Some("a") + ); + } + /// Regression: turning proxy takeover off restores Live from the backup. The /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`, /// never an inline `modelCatalog`). The restore must NOT route the config @@ -6581,4 +7068,148 @@ experimental_bearer_token = "PROXY_MANAGED" ); } } + + fn grok_provider_config(base_url: &str, api_key: &str) -> Value { + json!({ + "config": format!( + "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"{base_url}\"\nname = \"Grok\"\napi_key = \"{api_key}\"\napi_backend = \"responses\"\ncontext_window = 500000\n" + ) + }) + } + + #[tokio::test] + #[serial] + async fn hot_switch_grokbuild_updates_backup_and_current_provider() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + let provider_a = Provider::with_id( + "grok-a".to_string(), + "Grok A".to_string(), + grok_provider_config("https://a.example.com/v1", "a-key"), + None, + ); + let provider_b = Provider::with_id( + "grok-b".to_string(), + "Grok B".to_string(), + grok_provider_config("https://b.example.com/v1", "b-key"), + None, + ); + db.save_provider("grokbuild", &provider_a) + .expect("save provider a"); + db.save_provider("grokbuild", &provider_b) + .expect("save provider b"); + db.set_current_provider("grokbuild", "grok-a") + .expect("set db current"); + crate::settings::set_current_provider(&AppType::GrokBuild, Some("grok-a")) + .expect("set local current"); + let mut original_settings = provider_a.settings_config.clone(); + original_settings["config"] = json!(format!( + "{}\n[mcp_servers.demo]\ncommand = \"demo\"\n", + original_settings["config"] + .as_str() + .expect("provider config") + )); + db.save_live_backup( + "grokbuild", + &serde_json::to_string(&original_settings).expect("serialize backup"), + ) + .await + .expect("seed backup"); + + service + .hot_switch_provider("grokbuild", "grok-b") + .await + .expect("hot switch Grok Build"); + + assert_eq!( + crate::settings::get_effective_current_provider(&db, &AppType::GrokBuild) + .expect("read current") + .as_deref(), + Some("grok-b") + ); + let backup = db + .get_live_backup("grokbuild") + .await + .expect("read backup") + .expect("backup exists"); + let backup: Value = serde_json::from_str(&backup.original_config).expect("parse backup"); + assert!(backup["config"] + .as_str() + .is_some_and(|config| config.contains("https://b.example.com/v1"))); + assert!(backup["config"] + .as_str() + .is_some_and(|config| config.contains("[mcp_servers.demo]"))); + } + + #[tokio::test] + #[serial] + async fn failed_hot_switch_grokbuild_keeps_previous_current_and_backup() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + let provider_a = Provider::with_id( + "grok-a".to_string(), + "Grok A".to_string(), + grok_provider_config("https://a.example.com/v1", "a-key"), + None, + ); + let provider_b = Provider::with_id( + "grok-b".to_string(), + "Broken Grok".to_string(), + json!({ "config": "not valid toml = [" }), + None, + ); + db.save_provider("grokbuild", &provider_a) + .expect("save provider a"); + db.save_provider("grokbuild", &provider_b) + .expect("save provider b"); + db.set_current_provider("grokbuild", "grok-a") + .expect("set db current"); + crate::settings::set_current_provider(&AppType::GrokBuild, Some("grok-a")) + .expect("set local current"); + + let original_backup = + serde_json::to_string(&provider_a.settings_config).expect("serialize backup"); + db.save_live_backup("grokbuild", &original_backup) + .await + .expect("seed backup"); + let takeover = crate::grok_config::apply_proxy_takeover( + provider_a.settings_config["config"] + .as_str() + .expect("provider config"), + "http://127.0.0.1:15721/grokbuild/v1", + PROXY_TOKEN_PLACEHOLDER, + ) + .expect("build takeover config"); + service + .write_grok_live(&json!({ "config": takeover })) + .expect("seed taken-over live"); + + service + .hot_switch_provider("grokbuild", "grok-b") + .await + .expect_err("invalid Grok config must fail"); + + assert_eq!( + crate::settings::get_effective_current_provider(&db, &AppType::GrokBuild) + .expect("read current") + .as_deref(), + Some("grok-a") + ); + assert_eq!( + crate::settings::get_current_provider(&AppType::GrokBuild).as_deref(), + Some("grok-a") + ); + let backup = db + .get_live_backup("grokbuild") + .await + .expect("read backup") + .expect("backup exists"); + assert_eq!(backup.original_config, original_backup); + } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index fbf72bc86..85761c75f 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -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"), diff --git a/src-tauri/src/services/sql_helpers.rs b/src-tauri/src/services/sql_helpers.rs index 2007b50f5..a32049161 100644 --- a/src-tauri/src/services/sql_helpers.rs +++ b/src-tauri/src/services/sql_helpers.rs @@ -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] diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index c7ca1f714..839e94fb0 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -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 { - 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 { 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 { 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 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, 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}")), }; diff --git a/src-tauri/src/session_manager/providers/grokbuild.rs b/src-tauri/src/session_manager/providers/grokbuild.rs new file mode 100644 index 000000000..d3462b2d3 --- /dev/null +++ b/src-tauri/src/session_manager/providers/grokbuild.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct GrokSessionSummary { + info: GrokSessionInfo, + #[serde(default)] + session_summary: Option, + #[serde(default)] + generated_title: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + last_active_at: Option, +} + +pub fn session_roots() -> Vec { + 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 { + 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, 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::(&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 { + 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) { + 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 { + 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 { + 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::>(); + + 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()); + } +} diff --git a/src-tauri/src/session_manager/providers/mod.rs b/src-tauri/src/session_manager/providers/mod.rs index 394fdd1f8..6a16fb5a8 100644 --- a/src-tauri/src/session_manager/providers/mod.rs +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -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; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 6d627ee53..98ac3d7ea 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] + pub grok_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw_config_dir: Option, @@ -430,6 +436,9 @@ pub struct AppSettings { /// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_gemini: Option, + /// 当前 Grok Build 供应商 ID(本地存储,优先于数据库 is_current) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_grokbuild: Option, /// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_opencode: Option, @@ -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 { .map(|p| resolve_override_path(p)) } +pub fn get_grok_override_dir() -> Option { + 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 { let settings = settings_store().read().ok()?; settings @@ -940,6 +966,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { 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(), diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 95efbb87f..b33a00c54 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -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) -> SubscriptionQuota { SubscriptionQuota { tool: tool.to_string(), diff --git a/src-tauri/tests/app_type_parse.rs b/src-tauri/tests/app_type_parse.rs index 4abd316cc..e96632cc3 100644 --- a/src-tauri/tests/app_type_parse.rs +++ b/src-tauri/tests/app_type_parse.rs @@ -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) diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index f6df65876..8c1f560d9 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -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, }, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 15a30221b..c533b0f38 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -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, }, diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 29eb199c5..baf532419 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -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, }, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index dde2fe469..bbb11cf02 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -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, }, diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index 35ff1ee3a..d9b2f0259 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -31,6 +31,7 @@ pub fn reset_test_fs() { ".codex", ".cc-switch", ".gemini", + ".grok", ".config", ".openclaw", "profiles", diff --git a/src/App.tsx b/src/App.tsx index 645f1002d..1ac823033 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 }} diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 3ffdb09b8..06bc5368b 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -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", diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index ec827b2fd..8430a3e87 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -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 = ({ 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 { diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 0fd784b05..778de7d59 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -42,7 +42,7 @@ const McpFormModal: React.FC = ({ 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 = ({ 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 = ({ +
+ + setEnabledApps({ ...enabledApps, grokbuild: checked }) + } + /> + +
+
= ({ "claude-desktop": "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", + grokbuild: "AGENTS.md", opencode: "AGENTS.md", hermes: "AGENTS.md", }; diff --git a/src/components/prompts/PromptFormPanel.tsx b/src/components/prompts/PromptFormPanel.tsx index 1486dff06..b9bc9db09 100644 --- a/src/components/prompts/PromptFormPanel.tsx +++ b/src/components/prompts/PromptFormPanel.tsx @@ -29,6 +29,7 @@ const PromptFormPanel: React.FC = ({ "claude-desktop": "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", + grokbuild: "AGENTS.md", opencode: "AGENTS.md", openclaw: "AGENTS.md", hermes: "AGENTS.md", diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 11ba54412..10af95859 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -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 diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 2c7800478..a73cf246c 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -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({
= { claude: 8, "claude-desktop": 8, gemini: 8, + grokbuild: 12, opencode: 8, openclaw: 8, hermes: 8, diff --git a/src/components/providers/forms/GrokBuildProviderForm.tsx b/src/components/providers/forms/GrokBuildProviderForm.tsx new file mode 100644 index 000000000..b653bebef --- /dev/null +++ b/src/components/providers/forms/GrokBuildProviderForm.tsx @@ -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; + +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( + initialData ? null : "custom", + ); + const [category, setCategory] = useState( + initialData?.category ?? "custom", + ); + const [isPartner, setIsPartner] = useState( + initialData?.meta?.isPartner ?? false, + ); + const [partnerPromotionKey, setPartnerPromotionKey] = useState(); + 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( + (initialData?.meta?.apiFormat as CodexApiFormat | undefined) ?? + "openai_responses", + ); + const [anthropicAuthField, setAnthropicAuthField] = + useState( + 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(initialData?.meta?.codexChatReasoning ?? {}); + const [promptCacheRouting, setPromptCacheRouting] = + useState( + 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([]); + const [draftCustomEndpoints, setDraftCustomEndpoints] = useState( + [], + ); + + const form = useForm({ + 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(); + 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>, + ) => { + 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 ( +
+ + {!initialData && ( + + )} + + + +
+ + + {t("grokBuild.profile", { defaultValue: "客户端模型档位" })} + + { + const value = event.target.value; + setProfile(value); + syncStructuredConfig({ model: value }); + }} + placeholder="grok-4.5" + autoComplete="off" + /> + + + + + {t("grokBuild.apiBackend", { defaultValue: "API Backend" })} + + { + const value = event.target.value; + setApiBackend(value); + syncStructuredConfig({ apiBackend: value }); + }} + placeholder="responses" + autoComplete="off" + /> + + + + + {t("grokBuild.contextWindow", { defaultValue: "上下文窗口" })} + + { + const value = event.target.value; + setContextWindow(value); + syncStructuredConfig({ + contextWindow: Number.parseInt(value, 10), + }); + }} + /> + +
+ + { + 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} + /> + +
+ + {t("grokBuild.rawConfig", { defaultValue: "config.toml" })} + + + {rawConfigError && ( +

+ {t("grokBuild.invalidToml", { + error: rawConfigError, + defaultValue: `Invalid config.toml: ${rawConfigError}`, + })} +

+ )} +
+ + ( + + + + + + + )} + /> + + {showButtons && ( +
+ + +
+ )} + + + ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 75130ecbe..ec118dd1c 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -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 ; } + if (props.appId === "grokbuild") { + return ; + } return ; } diff --git a/src/components/proxy/ClaudeDesktopRouteToggle.tsx b/src/components/proxy/ClaudeDesktopRouteToggle.tsx index cc924d0fc..bf9aa950f 100644 --- a/src/components/proxy/ClaudeDesktopRouteToggle.tsx +++ b/src/components/proxy/ClaudeDesktopRouteToggle.tsx @@ -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; diff --git a/src/components/proxy/FailoverToggle.tsx b/src/components/proxy/FailoverToggle.tsx index 757a73018..739fb03b7 100644 --- a/src/components/proxy/FailoverToggle.tsx +++ b/src/components/proxy/FailoverToggle.tsx @@ -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", { diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 6166271af..a7b6fbda5 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -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: "应用接管", })}

-
- {(["claude", "codex", "gemini"] as const).map((appType) => { - const isEnabled = - takeoverStatus?.[ - appType as keyof typeof takeoverStatus - ] ?? false; - return ( -
- - {appType} - - - handleTakeoverChange(appType, checked) - } - disabled={setTakeoverForApp.isPending} - /> -
- ); - })} +
+ {(["claude", "codex", "gemini", "grokbuild"] as const).map( + (appType) => { + const isEnabled = + takeoverStatus?.[ + appType as keyof typeof takeoverStatus + ] ?? false; + return ( +
+ + {appType === "grokbuild" ? "Grok Build" : appType} + + + handleTakeoverChange(appType, checked) + } + disabled={setTakeoverForApp.isPending} + /> +
+ ); + }, + )}

{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) && (

@@ -459,6 +463,18 @@ export function ProxyPanel({ status={status} /> )} + + {grokQueue.length > 0 && ( + ({ + id: item.providerId, + name: item.providerName, + }))} + status={status} + /> + )}
)}
diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 61c831edd..fa2993d40 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -39,7 +39,9 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { ? "Codex" : activeApp === "gemini" ? "Gemini" - : "OpenCode"; + : activeApp === "grokbuild" + ? "Grok Build" + : "OpenCode"; const tooltipText = takeoverEnabled ? isRunning diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index 0c900a930..11178e0b2 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -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 }) { Codex
+ +
+ + Grok Build +
+
{ 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"; diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index cf5e1d524..117e36793 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -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 = { 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 = { claude: "claude", codex: "codex", gemini: "gemini", + grok: "grokbuild", opencode: "opencode", openclaw: "openclaw", hermes: "hermes", diff --git a/src/components/settings/AppVisibilitySettings.tsx b/src/components/settings/AppVisibilitySettings.tsx index f9f1195e2..7276b89a6 100644 --- a/src/components/settings/AppVisibilitySettings.tsx +++ b/src/components/settings/AppVisibilitySettings.tsx @@ -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")}

-
+
{APP_CONFIG.map((app) => { const isVisible = visibleApps[app.id]; // Disable button if this is the last visible app diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index 5966a1268..9ce3f8b0e 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -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")} /> + onDirectoryChange("grokbuild", val)} + onBrowse={() => onBrowseDirectory("grokbuild")} + onReset={() => onResetDirectory("grokbuild")} + /> + = ({ 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 = ({ claude: false, codex: false, gemini: false, + grokbuild: false, opencode: false, openclaw: false, hermes: false, @@ -815,6 +818,7 @@ const ImportSkillsDialog: React.FC = ({ claude: false, codex: false, gemini: false, + grokbuild: false, opencode: false, openclaw: false, hermes: false, @@ -828,6 +832,7 @@ const ImportSkillsDialog: React.FC = ({ claude: false, codex: false, gemini: false, + grokbuild: false, opencode: false, openclaw: false, hermes: false, diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx index 5e69b4278..1d3a939a2 100644 --- a/src/components/usage/PricingConfigPanel.tsx +++ b/src/components/usage/PricingConfigPanel.tsx @@ -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( 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] = { diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index a03fa833c..0da44b0f1 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -63,6 +63,7 @@ const APP_FILTER_ICON: Record = { claude: "claude", codex: "openai", gemini: "gemini", + grokbuild: "grok", opencode: "opencode", }; diff --git a/src/components/usage/UsageHero.tsx b/src/components/usage/UsageHero.tsx index 8cc524f4d..8a17ccfdb 100644 --- a/src/components/usage/UsageHero.tsx +++ b/src/components/usage/UsageHero.tsx @@ -61,6 +61,10 @@ const TITLE_THEMES: Record = { 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", diff --git a/src/config/appConfig.tsx b/src/config/appConfig.tsx index a2103a926..c3be05c45 100644 --- a/src/config/appConfig.tsx +++ b/src/config/appConfig.tsx @@ -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 = { 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: ( + + ), + 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: ( diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts index 70347328a..35d0ed801 100644 --- a/src/hooks/useDirectorySettings.ts +++ b/src/hooks/useDirectorySettings.ts @@ -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, diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index daa463224..84b9ce69e 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -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)?.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)?.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"; diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index 072c9b884..c7995e154 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -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, // 启动/停止(总开关) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index ef1a66757..492da10a4 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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) ) { diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index 03c7e34a3..bf7c9af09 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -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, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2924ee842..714cbcacf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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//.claude", "browsePlaceholderCodex": "e.g., /home//.codex", "browsePlaceholderGemini": "e.g., /home//.gemini", + "browsePlaceholderGrok": "e.g., /home//.grok", "browsePlaceholderOpencode": "e.g., /home//.config/opencode", "browsePlaceholderOpenclaw": "e.g., /home//.openclaw", "browsePlaceholderHermes": "e.g., /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": "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" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ddcbf6f8a..2b61d2d2b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 61a965442..5c6865868 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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" diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6b60c0b42..9bd52b7cb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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" diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 191ca384b..3722ac896 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -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; diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 72b5ec28d..08962a091 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -4,6 +4,7 @@ export type AppId = | "claude-desktop" | "codex" | "gemini" + | "grokbuild" | "opencode" | "openclaw" | "hermes"; diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index a2a8be7c8..d9ba9b9b9 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -227,7 +227,9 @@ export function useSetAutoFailoverEnabled() { ? "Claude" : variables.appType === "codex" ? "Codex" - : "Gemini"; + : variables.appType === "grokbuild" + ? "Grok Build" + : "Gemini"; toast.success( variables.enabled diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 2d2780d8e..61f17166d 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -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(), diff --git a/src/types.ts b/src/types.ts index afb99def3..585e95429 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 75f8c24b4..01e33116d 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -46,6 +46,7 @@ export interface ProxyTakeoverStatus { "claude-desktop"?: boolean; codex: boolean; gemini: boolean; + grokbuild: boolean; opencode: boolean; openclaw: boolean; hermes: boolean; diff --git a/src/types/usage.ts b/src/types/usage.ts index 256d58aec..b791a34df 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -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 = [ "claude", "codex", "gemini", + "grokbuild", "opencode", ]; @@ -198,6 +199,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray = [ export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet = new Set([ "codex", "gemini", + "grokbuild", ]); /** Subset of request-log fields needed to derive cache-normalized input. */ diff --git a/src/utils/grokBuildConfig.test.ts b/src/utils/grokBuildConfig.test.ts new file mode 100644 index 000000000..58b614f85 --- /dev/null +++ b/src/utils/grokBuildConfig.test.ts @@ -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"); + }); +}); diff --git a/src/utils/grokBuildConfig.ts b/src/utils/grokBuildConfig.ts new file mode 100644 index 000000000..38dd04406 --- /dev/null +++ b/src/utils/grokBuildConfig.ts @@ -0,0 +1,166 @@ +import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; + +export const GROK_BUILD_DEFAULT_MODEL = "grok-4.5"; +export const GROK_BUILD_DEFAULT_API_BACKEND = "responses"; +export const GROK_BUILD_DEFAULT_CONTEXT_WINDOW = 500000; + +export interface GrokBuildConfigValues { + /** Client-visible profile selected by [models].default. */ + model: string; + /** Real model sent to the upstream provider. */ + upstreamModel?: string; + baseUrl: string; + name: string; + apiKey: string; + envKey?: string; + apiBackend: string; + contextWindow: number; +} + +const asRecord = (value: unknown): Record | undefined => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; + +const asString = (value: unknown, fallback = "") => + typeof value === "string" ? value : fallback; + +export function parseGrokBuildConfig( + configToml: string | undefined, + fallbackName = "", +): GrokBuildConfigValues { + const fallback: GrokBuildConfigValues = { + model: GROK_BUILD_DEFAULT_MODEL, + upstreamModel: GROK_BUILD_DEFAULT_MODEL, + baseUrl: "", + name: fallbackName, + apiKey: "", + apiBackend: GROK_BUILD_DEFAULT_API_BACKEND, + contextWindow: GROK_BUILD_DEFAULT_CONTEXT_WINDOW, + }; + + if (!configToml?.trim()) return fallback; + + try { + const root = asRecord(parseToml(configToml)); + const models = asRecord(root?.models); + const defaultModel = asString(models?.default, GROK_BUILD_DEFAULT_MODEL); + const modelTables = asRecord(root?.model); + const selectedModel = asRecord(modelTables?.[defaultModel]); + const rawContextWindow = selectedModel?.context_window; + + return { + model: defaultModel, + upstreamModel: asString(selectedModel?.model, defaultModel), + baseUrl: asString(selectedModel?.base_url), + name: asString(selectedModel?.name, fallbackName), + apiKey: asString(selectedModel?.api_key), + envKey: asString(selectedModel?.env_key), + apiBackend: asString( + selectedModel?.api_backend, + GROK_BUILD_DEFAULT_API_BACKEND, + ), + contextWindow: + typeof rawContextWindow === "number" && + Number.isInteger(rawContextWindow) && + rawContextWindow > 0 + ? rawContextWindow + : GROK_BUILD_DEFAULT_CONTEXT_WINDOW, + }; + } catch { + return fallback; + } +} + +export function buildGrokBuildConfig(values: GrokBuildConfigValues): string { + return updateGrokBuildConfig(undefined, values); +} + +export function updateGrokBuildConfig( + configToml: string | undefined, + values: GrokBuildConfigValues, +): string { + const profile = values.model.trim() || GROK_BUILD_DEFAULT_MODEL; + const upstreamModel = values.upstreamModel?.trim() || profile; + let config: Record = {}; + + try { + config = asRecord(configToml?.trim() ? parseToml(configToml) : {}) ?? {}; + } catch { + config = {}; + } + + const existingModels = asRecord(config.models) ?? {}; + const previousProfile = asString(existingModels.default, profile); + config.models = { ...existingModels, default: profile }; + + const modelTables = asRecord(config.model) ?? {}; + const existingSelected = + asRecord(modelTables[profile]) ?? + asRecord(modelTables[previousProfile]) ?? + {}; + const apiKey = values.apiKey.trim(); + const envKey = + values.envKey?.trim() || asString(existingSelected.env_key).trim(); + const updatedSelected: Record = { + ...existingSelected, + model: upstreamModel, + base_url: values.baseUrl.trim(), + name: values.name.trim(), + api_backend: values.apiBackend.trim() || GROK_BUILD_DEFAULT_API_BACKEND, + context_window: + Number.isInteger(values.contextWindow) && values.contextWindow > 0 + ? values.contextWindow + : GROK_BUILD_DEFAULT_CONTEXT_WINDOW, + }; + if (apiKey) updatedSelected.api_key = apiKey; + else delete updatedSelected.api_key; + if (envKey) updatedSelected.env_key = envKey; + else delete updatedSelected.env_key; + + config.model = { + ...modelTables, + [profile]: updatedSelected, + }; + + if (previousProfile !== profile && previousProfile in modelTables) { + delete (config.model as Record)[previousProfile]; + } + + return `${stringifyToml(config).trim()}\n`; +} + +export function validateGrokBuildConfig(configToml: string): string | null { + if (!configToml.trim()) return "config.toml must not be empty"; + try { + const root = asRecord(parseToml(configToml)); + const models = asRecord(root?.models); + const profile = asString(models?.default).trim(); + const selected = asRecord(asRecord(root?.model)?.[profile]); + if (!profile || !selected) return "Missing [models] default model table"; + for (const field of ["model", "base_url", "name", "api_backend"]) { + if (!asString(selected[field]).trim()) return `Missing ${field}`; + } + if ( + !asString(selected.api_key).trim() && + !asString(selected.env_key).trim() + ) { + return "Missing api_key or env_key"; + } + const contextWindow = selected.context_window; + if ( + typeof contextWindow !== "number" || + !Number.isInteger(contextWindow) || + contextWindow <= 0 + ) { + return "context_window must be a positive integer"; + } + return null; + } catch (error) { + return error instanceof Error ? error.message : "Invalid TOML"; + } +} + +export function extractGrokBuildBaseUrl(configToml: string): string { + return parseGrokBuildConfig(configToml).baseUrl; +} diff --git a/src/utils/providerIcon.test.ts b/src/utils/providerIcon.test.ts new file mode 100644 index 000000000..589ff984b --- /dev/null +++ b/src/utils/providerIcon.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { resolveProviderIcon } from "./providerIcon"; + +describe("resolveProviderIcon", () => { + it("clears the legacy automatic Grok Build icon", () => { + expect(resolveProviderIcon("grokbuild", "grok", "")).toBeUndefined(); + expect(resolveProviderIcon("grokbuild", "grok")).toBeUndefined(); + }); + + it("preserves a Grok icon explicitly selected by the user", () => { + expect(resolveProviderIcon("grokbuild", "grok", "currentColor")).toBe( + "grok", + ); + }); + + it("does not reinterpret another app's provider icon", () => { + expect(resolveProviderIcon("codex", "grok", "")).toBe("grok"); + }); + + it("normalizes an empty icon to the initials fallback", () => { + expect(resolveProviderIcon("grokbuild", " ", "")).toBeUndefined(); + }); +}); diff --git a/src/utils/providerIcon.ts b/src/utils/providerIcon.ts new file mode 100644 index 000000000..55e2f0168 --- /dev/null +++ b/src/utils/providerIcon.ts @@ -0,0 +1,26 @@ +import type { AppId } from "@/lib/api/types"; + +/** + * Grok Build providers created before the provider-icon rules were aligned + * received the Grok app icon automatically. The icon picker records the + * selected icon's default color (`currentColor` for Grok), so an empty color + * identifies the old automatic value without hiding an explicit user choice. + */ +export function resolveProviderIcon( + appId: AppId, + icon?: string, + iconColor?: string, +): string | undefined { + const normalizedIcon = icon?.trim(); + if (!normalizedIcon) return undefined; + + if ( + appId === "grokbuild" && + normalizedIcon === "grok" && + !iconColor?.trim() + ) { + return undefined; + } + + return normalizedIcon; +} diff --git a/tests/components/AddProviderDialog.test.tsx b/tests/components/AddProviderDialog.test.tsx index 957353753..37908ed1d 100644 --- a/tests/components/AddProviderDialog.test.tsx +++ b/tests/components/AddProviderDialog.test.tsx @@ -125,4 +125,45 @@ describe("AddProviderDialog", () => { }, }); }); + + it("新建 Grok Build 自定义供应商时不补默认 Grok 图标", async () => { + const handleSubmit = vi.fn().mockResolvedValue(undefined); + + mockFormValues = { + name: "tes 1", + websiteUrl: "", + icon: "", + iconColor: "", + settingsConfig: JSON.stringify({ + config: `[models] +default = "grok-4.5" + +[model."grok-4.5"] +model = "grok-4.5" +base_url = "https://grok.example.com/v1" +name = "tes 1" +api_key = "secret" +api_backend = "responses" +context_window = 500000 +`, + }), + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "common.add" })); + + await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); + + const submitted = handleSubmit.mock.calls[0][0]; + expect(submitted.icon).toBeUndefined(); + expect(submitted.iconColor).toBeUndefined(); + }); }); diff --git a/tests/components/GrokBuildProviderForm.test.tsx b/tests/components/GrokBuildProviderForm.test.tsx new file mode 100644 index 000000000..17df0540d --- /dev/null +++ b/tests/components/GrokBuildProviderForm.test.tsx @@ -0,0 +1,178 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { parse as parseToml } from "smol-toml"; +import { describe, expect, it, vi } from "vitest"; +import { GrokBuildProviderForm } from "@/components/providers/forms/GrokBuildProviderForm"; + +vi.mock("@/components/JsonEditor", () => ({ + default: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => ( +