mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c7c1ebb56 |
@@ -24,12 +24,10 @@ jobs:
|
||||
node-version: "20"
|
||||
|
||||
- name: Setup pnpm
|
||||
env:
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm --version
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
|
||||
@@ -12,6 +12,6 @@ jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
- uses: actions/labeler@v7
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -83,11 +83,19 @@ jobs:
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Setup pnpm
|
||||
env:
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
||||
if: runner.os != 'Windows' || matrix.arch != 'arm64'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
|
||||
- name: Setup pnpm (Windows ARM64)
|
||||
if: runner.os == 'Windows' && matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
corepack enable
|
||||
corepack install
|
||||
corepack prepare pnpm@10.12.3 --activate
|
||||
node --version
|
||||
pnpm --version
|
||||
|
||||
|
||||
@@ -24,14 +24,6 @@ There are many ways to contribute:
|
||||
- Rust 1.85+ and Cargo
|
||||
- [Tauri 2.0 prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
> **pnpm version**: the exact version is pinned via the `packageManager` field
|
||||
> in `package.json` (and used by CI, which sets pnpm up through Corepack).
|
||||
> With Corepack enabled locally (`corepack enable`), your pnpm shim switches
|
||||
> to the pinned version automatically, with a one-time download on first use;
|
||||
> without Corepack, any pnpm 8+ keeps working (`package-manager-strict-version`
|
||||
> defaults to off). To upgrade pnpm, edit the `packageManager` field — Dependabot
|
||||
> bumps no longer cover the version.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
@@ -160,12 +152,6 @@ CC Switch supports three languages. When modifying user-facing text:
|
||||
- Rust 1.85+ 和 Cargo
|
||||
- [Tauri 2.0 开发环境](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
> **pnpm 版本**:确切版本由 `package.json` 的 `packageManager` 字段固定
|
||||
> (CI 通过 Corepack 按此字段安装)。本地启用 Corepack(`corepack enable`)
|
||||
> 后,pnpm shim 会自动切到该版本(首次使用需一次性下载);未启用 Corepack
|
||||
> 时任意 pnpm 8+ 均可正常使用(`package-manager-strict-version` 默认关闭)。
|
||||
> 升级 pnpm 请直接修改 `packageManager` 字段,不再由 Dependabot 代劳。
|
||||
|
||||
### 快速开始
|
||||
|
||||
```bash
|
||||
|
||||
@@ -25,11 +25,9 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Deutsch](README_
|
||||
<details open>
|
||||
<summary>Click to collapse</summary>
|
||||
|
||||
[](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
|
||||
Kimi K3 is Moonshot AI's most capable model and the world's first open 3T-class model. With 2.8 trillion parameters, native vision, and a 1-million-token context window, K3 delivers frontier performance across long-horizon coding, knowledge work, and reasoning. CC Switch makes it easy to configure and switch to Kimi across agentic tools. **[Click here to start using Kimi](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)**
|
||||
|
||||
**New user top-up bonus**: register via [this link](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch) and complete your first top-up to receive 10% of the amount as bonus API credit, up to CNY ¥1,000.
|
||||
Kimi K3 is Moonshot AI's most capable model and the world's first open 3T-class model. With 2.8 trillion parameters, native vision, and a 1-million-token context window, K3 delivers frontier performance across long-horizon coding, knowledge work, and reasoning. CC Switch makes it easy to configure and switch to Kimi across agentic tools. **[Click here to start using Kimi](https://platform.kimi.ai?aff=cc-switch)**
|
||||
|
||||
Doing mostly coding work? Try the **[Kimi Code subscription](https://www.kimi.com/code/?aff=cc-switch)**.
|
||||
|
||||
@@ -136,6 +134,11 @@ TeamoRouter also offers enterprise features including centralized billing, team
|
||||
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">this link</a> and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>Thanks to <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> for sponsoring this project! NekoCode provides developers with a stable, efficient, and reliable API relay service for Claude, Codex, and other AI models. With transparent pricing and flexible pay-as-you-go billing, it offers a simple and cost-effective way to access AI models. CC Switch users can enjoy an exclusive 10% discount: register via <a href="https://nekocode.ai?aff=CCSWITCH">this link</a> and enter promo code <code>cc-switch</code> during recharge to receive 10% off your top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
|
||||
<td>Thanks to <a href="https://a6api.com/register?aff=AqNr">A6API</a> for sponsoring this project! A6API is a one-stop AI model API aggregation platform covering Claude, GPT, Gemini, Codex, and other mainstream models. Multiple vendors can list their supply on the platform, so the same model can be quoted competitively by several upstream providers. Smart routing automatically picks the more stable, lower-priced route available and fails over automatically, helping you reduce failed requests, cut costs, and improve stability. Whether you are an individual developer, an AI product team, or a studio, you can integrate quickly through a unified interface — compatible with all formats, with low migration cost. New users who register via <a href="https://a6api.com/register?aff=AqNr">this link</a> receive free trial credits: try it first, then use it at a low price.</td>
|
||||
|
||||
+7
-4
@@ -25,11 +25,9 @@
|
||||
<details open>
|
||||
<summary>Zum Einklappen klicken</summary>
|
||||
|
||||
[](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
|
||||
Kimi K3 ist das bislang leistungsstärkste Modell von Moonshot AI und das weltweit erste offene Modell der 3T-Klasse. Mit 2,8 Billionen Parametern, nativen visuellen Fähigkeiten und einem Kontextfenster von 1 Million Token liefert K3 Spitzenleistung bei langfristigen Programmieraufgaben, Wissensarbeit und Reasoning. Mit CC Switch lässt sich Kimi in den verschiedensten Agenten-Tools bequem konfigurieren und umschalten. **[Hier klicken, um Kimi zu nutzen](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)**
|
||||
|
||||
**Bonus für die erste Aufladung neuer Nutzer**: Registrieren Sie sich über [diesen Link](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch) und schließen Sie Ihre erste Aufladung ab, um 10 % des Betrags als Bonus-API-Guthaben zu erhalten – bis zu CNY ¥1.000.
|
||||
Kimi K3 ist das bislang leistungsstärkste Modell von Moonshot AI und das weltweit erste offene Modell der 3T-Klasse. Mit 2,8 Billionen Parametern, nativen visuellen Fähigkeiten und einem Kontextfenster von 1 Million Token liefert K3 Spitzenleistung bei langfristigen Programmieraufgaben, Wissensarbeit und Reasoning. Mit CC Switch lässt sich Kimi in den verschiedensten Agenten-Tools bequem konfigurieren und umschalten. **[Hier klicken, um Kimi zu nutzen](https://platform.kimi.ai?aff=cc-switch)**
|
||||
|
||||
Hauptsächlich mit Programmierung beschäftigt? Probieren Sie das **[Kimi-Code-Abo](https://www.kimi.com/code/?aff=cc-switch)** aus!
|
||||
|
||||
@@ -136,6 +134,11 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
|
||||
<td>Danke an SiliconFlow für die Unterstützung dieses Projekts! SiliconFlow ist eine leistungsstarke KI-Infrastruktur- und Modell-API-Plattform, die schnellen und zuverlässigen Zugriff auf Sprach-, Audio-, Bild- und Videomodelle an einem Ort bietet. Mit nutzungsbasierter Abrechnung, breiter Unterstützung multimodaler Modelle, Hochgeschwindigkeitsinferenz und unternehmensgerechter Stabilität hilft SiliconFlow Entwicklern und Teams, KI-Anwendungen effizienter zu erstellen und zu skalieren. Registrieren Sie sich über <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">diesen Link</a> und schließen Sie die Identitätsverifizierung ab, um ein Bonusguthaben von ¥16 zu erhalten, das für alle Modelle der Plattform nutzbar ist. SiliconFlow ist zudem nun mit OpenClaw kompatibel, sodass Nutzer einen SiliconFlow-API-Schlüssel verbinden und große KI-Modelle kostenlos aufrufen können.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>Vielen Dank an <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> für die Unterstützung dieses Projekts! NekoCode bietet Entwicklern einen stabilen, effizienten und zuverlässigen API-Relay-Dienst für Claude, Codex und weitere KI-Modelle. Mit transparenter Preisgestaltung und flexibler nutzungsbasierter Abrechnung bietet es einen einfachen und kostengünstigen Zugang zu KI-Modellen. CC-Switch-Nutzer erhalten einen exklusiven Rabatt von 10 %: Registrieren Sie sich über <a href="https://nekocode.ai?aff=CCSWITCH">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode <code>cc-switch</code> ein, um 10 % Rabatt auf Ihre Aufladung zu erhalten!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
|
||||
<td>Vielen Dank an <a href="https://a6api.com/register?aff=AqNr">A6API</a> für die Unterstützung dieses Projekts! A6API ist eine All-in-one-Aggregationsplattform für KI-Modell-APIs und deckt Claude, GPT, Gemini, Codex und weitere gängige Modelle ab. Mehrere Anbieter können ihr Angebot einstellen, sodass dasselbe Modell von verschiedenen Upstream-Anbietern im Preiswettbewerb bereitgestellt wird. Intelligentes Routing wählt automatisch die stabilere und günstigere verfügbare Route und schaltet bei Fehlern automatisch um – das reduziert fehlgeschlagene Anfragen, senkt die Kosten und erhöht die Stabilität. Ob einzelne Entwickler, KI-Produktteams oder Studios: Die Anbindung erfolgt schnell über eine einheitliche Schnittstelle, kompatibel mit allen Formaten und mit geringem Migrationsaufwand. Neue Nutzer erhalten bei der Registrierung über <a href="https://a6api.com/register?aff=AqNr">diesen Link</a> kostenloses Testguthaben – erst testen, dann günstig loslegen.</td>
|
||||
|
||||
+7
-4
@@ -25,11 +25,9 @@
|
||||
<details open>
|
||||
<summary>クリックで折りたたむ</summary>
|
||||
|
||||
[](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
|
||||
Kimi K3 は Moonshot AI がこれまでに開発した中で最も高性能なモデルであり、世界初のオープンソース 3T クラスモデルです。2.8 兆パラメータ、ネイティブな視覚能力、100 万トークンのコンテキストウィンドウを備え、長期にわたるコーディング、ナレッジワーク、推論タスクにおいてフロンティア級の性能を発揮します。CC Switch を使えば、さまざまなエージェントツールで Kimi を手軽に設定・切り替えできます。**[ここをクリックして Kimi を使い始める](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)**
|
||||
|
||||
**新規ユーザー初回チャージ特典**:[こちらのリンク](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)から登録し、初回チャージに成功すると、チャージ金額の 10%(最大 CNY ¥1,000)が API クレジットとして進呈されます。
|
||||
Kimi K3 は Moonshot AI がこれまでに開発した中で最も高性能なモデルであり、世界初のオープンソース 3T クラスモデルです。2.8 兆パラメータ、ネイティブな視覚能力、100 万トークンのコンテキストウィンドウを備え、長期にわたるコーディング、ナレッジワーク、推論タスクにおいてフロンティア級の性能を発揮します。CC Switch を使えば、さまざまなエージェントツールで Kimi を手軽に設定・切り替えできます。**[ここをクリックして Kimi を使い始める](https://platform.kimi.ai?aff=cc-switch)**
|
||||
|
||||
コーディング作業がメインですか?**[Kimi Code サブスクリプション](https://www.kimi.com/code/?aff=cc-switch)** をぜひお試しください!
|
||||
|
||||
@@ -136,6 +134,11 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>本プロジェクトをご支援いただいている <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> に感謝します!NekoCode は、Claude や Codex などの AI モデルに対応した、安定性・効率性・信頼性に優れた API 中継サービスを提供しています。料金体系は明瞭で、柔軟な従量課金にも対応しています。CC Switch ユーザー限定の 10%オフ特典:<a href="https://nekocode.ai?aff=CCSWITCH">こちらのリンク</a> から登録し、チャージ時にクーポンコード <code>cc-switch</code> を入力すると、チャージが 10%オフになります!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
|
||||
<td>本プロジェクトをご支援いただいている <a href="https://a6api.com/register?aff=AqNr">A6API</a> に感謝します!A6API は、Claude、GPT、Gemini、Codex などの主要モデルを網羅するワンストップの AI モデル API アグリゲーションプラットフォームです。複数のベンダーが出品でき、同じモデルを複数の上流プロバイダーが競争価格で提供します。スマートルーティングにより、より安定して安価な利用可能ルートを自動で選択し、失敗時には自動で切り替えるため、リクエストの失敗を減らし、コストを抑え、安定性を高められます。個人開発者でも、AI プロダクトチームでも、スタジオでも、統一されたインターフェースからすぐに接続でき、あらゆるフォーマットに対応、移行コストも低く抑えられます。<a href="https://a6api.com/register?aff=AqNr">こちらのリンク</a> から新規登録すると無料の体験クレジットがもらえます。まず試してから、低価格で使い始められます。</td>
|
||||
|
||||
+7
-4
@@ -25,11 +25,9 @@
|
||||
<details open>
|
||||
<summary>点击折叠</summary>
|
||||
|
||||
[](https://platform.kimi.com?track_id=track-6840233b42274ab4bcfd283e2bdd2aee&aff=cc-switch)
|
||||
[](https://platform.kimi.com?aff=cc-switch)
|
||||
|
||||
Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3T 级模型。K3 拥有 2.8T 参数、原生视觉能力与 100 万 Token 上下文,在长程编码、知识工作和推理任务中展现前沿性能。使用 CC Switch,可以在各类 Agent 工具中便捷配置和切换 Kimi。**[点击此处开始使用 Kimi](https://platform.kimi.com?track_id=track-6840233b42274ab4bcfd283e2bdd2aee&aff=cc-switch)**
|
||||
|
||||
**新用户首充福利**:通过[此链接](https://platform.kimi.com?track_id=track-6840233b42274ab4bcfd283e2bdd2aee&aff=cc-switch)注册并首次成功充值,即可获赠充值金额 10% 的 API 额度,最高赠送 ¥1000。
|
||||
Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3T 级模型。K3 拥有 2.8T 参数、原生视觉能力与 100 万 Token 上下文,在长程编码、知识工作和推理任务中展现前沿性能。使用 CC Switch,可以在各类 Agent 工具中便捷配置和切换 Kimi。**[点击此处开始使用 Kimi](https://platform.kimi.com?aff=cc-switch)**
|
||||
|
||||
主要进行编程工作?可以试试 **[Kimi Code 订阅](https://www.kimi.com/code/?aff=cc-switch)**。
|
||||
|
||||
@@ -136,6 +134,11 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">此链接</a>注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>感谢 <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> 赞助本项目!NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明,接入便捷,支持灵活的按量计费。CC Switch 用户专享 9 折福利:通过 <a href="https://nekocode.ai?aff=CCSWITCH">此链接</a> 注册,并在充值时输入优惠码 <code>cc-switch</code>,即可享受充值 9 折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-zh.jpg" alt="A6API" width="150"></a></td>
|
||||
<td>感谢 <a href="https://a6api.com/register?aff=AqNr">A6API</a> 赞助本项目!A6API 是一站式 AI 模型 API 聚合平台,覆盖 Claude、GPT、Gemini、Codex 等主流模型,支持多商家入驻供货,同一个模型可由多个上游商家竞争报价。平台通过智能路由自动优选更稳定、更低价的可用线路,并支持失败自动切换,帮助用户减少请求失败、降低调用成本、提升使用稳定性。无论你是开发者、AI 产品团队还是工作室,都可以通过统一接口快速接入,兼容所有格式,迁移成本低,使用更省心。新用户通过 <a href="https://a6api.com/register?aff=AqNr">此链接</a> 注册即可获得免费体验额度,先试再用,低价开用。</td>
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"keywords": [],
|
||||
"author": "Jason Young",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.8.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
|
||||
Generated
-1
@@ -783,7 +783,6 @@ dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"json-five",
|
||||
"json5",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
|
||||
@@ -89,9 +89,6 @@ tauri-plugin-single-instance = "2"
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
|
||||
[target.'cfg(not(target_os = "windows"))'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = [
|
||||
|
||||
+33
-184
@@ -2,8 +2,8 @@ use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::{
|
||||
atomic_write, delete_file, get_home_dir, path_is_within, read_json_file,
|
||||
sanitize_provider_name, write_json_file, write_text_file,
|
||||
atomic_write, delete_file, get_home_dir, read_json_file, sanitize_provider_name,
|
||||
write_json_file, write_text_file,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::model_capabilities::{image_input_capability_from_modalities, ImageInputCapability};
|
||||
@@ -1359,28 +1359,17 @@ pub fn prepare_codex_config_text_with_model_catalog(
|
||||
/// All failure modes (missing file, parse error, no `model_catalog_json`,
|
||||
/// entries without `slug`) collapse to `Ok(None)` so callers can treat this
|
||||
/// as best-effort enrichment without making `read_live_settings` brittle.
|
||||
/// 模型目录文件读取上限(32 MiB)。目录 JSON 正常只有几百 KiB;超过则视为异常,
|
||||
/// 避免指向外部大文件时耗尽内存。
|
||||
const MAX_CODEX_CATALOG_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
pub fn read_codex_model_catalog_simplified_from_live() -> Result<Option<Value>, AppError> {
|
||||
let config_text = read_codex_config_text()?;
|
||||
let config_dir = get_codex_config_dir();
|
||||
let Some(catalog_path) = resolve_cc_switch_catalog_path(&config_text, &config_dir) else {
|
||||
let generated_path = get_codex_model_catalog_path();
|
||||
let Some(catalog_path) = resolve_cc_switch_catalog_path(&config_text, &generated_path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !catalog_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let catalog_text = match read_limited_string(&catalog_path, MAX_CODEX_CATALOG_BYTES) {
|
||||
Ok(text) => text,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"拒绝读取越界或过大的 Codex 模型目录 {}: {error}",
|
||||
catalog_path.display()
|
||||
);
|
||||
let Ok(catalog_text) = fs::read_to_string(&catalog_path) else {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
Ok(build_simplified_catalog_from_texts(
|
||||
&config_text,
|
||||
@@ -1388,31 +1377,12 @@ pub fn read_codex_model_catalog_simplified_from_live() -> Result<Option<Value>,
|
||||
))
|
||||
}
|
||||
|
||||
/// 安全地读取文件为字符串,并在超过字节上限时返回错误。
|
||||
pub(crate) fn read_limited_string(path: &Path, max_bytes: u64) -> Result<String, AppError> {
|
||||
let metadata = fs::metadata(path).map_err(|error| AppError::io(path, error))?;
|
||||
if metadata.len() > max_bytes {
|
||||
return Err(AppError::Config(format!(
|
||||
"文件 {} 超过大小上限 {} 字节",
|
||||
path.display(),
|
||||
max_bytes
|
||||
)));
|
||||
}
|
||||
fs::read_to_string(path).map_err(|error| AppError::io(path, error))
|
||||
}
|
||||
|
||||
/// Read the cc-switch Codex model catalog file with a size cap.
|
||||
pub(crate) fn read_codex_model_catalog_text(path: &Path) -> Result<String, AppError> {
|
||||
read_limited_string(path, MAX_CODEX_CATALOG_BYTES)
|
||||
}
|
||||
|
||||
/// Given `config.toml` text, resolve the on-disk path of the cc-switch–owned
|
||||
/// catalog file (returns `None` if `model_catalog_json` is absent or points at
|
||||
/// a file we don't own). Relative paths are resolved under `base_dir`;
|
||||
/// absolute paths must still be inside `base_dir`.
|
||||
/// a file we don't own). Relative paths fall back to `generated_path`.
|
||||
pub(crate) fn resolve_cc_switch_catalog_path(
|
||||
config_text: &str,
|
||||
base_dir: &Path,
|
||||
generated_path: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
if config_text.trim().is_empty() {
|
||||
return None;
|
||||
@@ -1431,59 +1401,11 @@ pub(crate) fn resolve_cc_switch_catalog_path(
|
||||
return None;
|
||||
}
|
||||
|
||||
// 注意(有意的行为变更):Windows 上 `/…` 形式的旧 WSL 风格 Linux 路径也会
|
||||
// 被视为绝对路径,从而在下方的包含性校验中失败——此前这类路径会因无法匹配
|
||||
// 生成文件名而回退为按文件名解析、碰巧能工作。可接受:下一次切换供应商时
|
||||
// 写入侧会重新落一个裸文件名,配置自愈(见
|
||||
// `set_catalog_json_none_removes_cc_switch_owned_by_filename` 的场景注释)。
|
||||
let is_unix_absolute = catalog_path_str.starts_with('/');
|
||||
let resolved = if referenced_path.is_absolute() || is_unix_absolute {
|
||||
referenced_path.to_path_buf()
|
||||
if referenced_path.is_absolute() {
|
||||
Some(referenced_path.to_path_buf())
|
||||
} else {
|
||||
base_dir.join(referenced_path)
|
||||
};
|
||||
|
||||
if !path_is_within(base_dir, &resolved) {
|
||||
log::warn!(
|
||||
"Codex model_catalog_json 指向配置目录外: {}(允许目录: {})",
|
||||
resolved.display(),
|
||||
base_dir.display()
|
||||
);
|
||||
return None;
|
||||
Some(generated_path.to_path_buf())
|
||||
}
|
||||
|
||||
// 词法包含不等于运行时包含:配置目录内的符号链接(如 ~/.codex/link ->
|
||||
// /etc)能让 `link/cc-switch-model-catalog.json` 通过上面的检查,读取却
|
||||
// 落到目录外。文件存在时把真实路径 canonicalize 出来再校验一次,并把
|
||||
// canonical 路径返回给调用方——后续读取不再经过 symlink 组件。
|
||||
if resolved.exists() {
|
||||
let canonical = match fs::canonicalize(&resolved) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Codex model_catalog_json canonicalize 失败: {}: {error}",
|
||||
resolved.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// base 同样 canonicalize,保证两侧前缀一致(Windows \\?\、
|
||||
// macOS /tmp -> /private/tmp);base 失败时退回词法 base——
|
||||
// 词法 base 与 canonical 路径比较只会误拒(退化为不读),不会误放。
|
||||
let canonical_base = fs::canonicalize(base_dir).unwrap_or_else(|_| base_dir.to_path_buf());
|
||||
if !path_is_within(&canonical_base, &canonical) {
|
||||
log::warn!(
|
||||
"Codex model_catalog_json 经符号链接解析到配置目录外: {} -> {}(允许目录: {})",
|
||||
resolved.display(),
|
||||
canonical.display(),
|
||||
canonical_base.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
return Some(canonical);
|
||||
}
|
||||
|
||||
Some(resolved)
|
||||
}
|
||||
|
||||
/// Pure reverse-parsing core: convert Codex catalog JSON text back into the
|
||||
@@ -3929,30 +3851,30 @@ web_search = "disabled"
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_path_returns_none_when_config_missing_field() {
|
||||
let base = PathBuf::from("/tmp/.codex");
|
||||
assert!(resolve_cc_switch_catalog_path("", &base).is_none());
|
||||
let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json");
|
||||
assert!(resolve_cc_switch_catalog_path("", &generated).is_none());
|
||||
assert!(
|
||||
resolve_cc_switch_catalog_path("model = \"gpt-5\"", &base).is_none(),
|
||||
resolve_cc_switch_catalog_path("model = \"gpt-5\"", &generated).is_none(),
|
||||
"no model_catalog_json field should yield None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_path_accepts_cc_switch_owned_file() {
|
||||
let base = PathBuf::from("/tmp/.codex");
|
||||
let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json");
|
||||
let config = r#"model_catalog_json = "/tmp/.codex/cc-switch-model-catalog.json"
|
||||
"#;
|
||||
let resolved = resolve_cc_switch_catalog_path(config, &base).expect("path resolves");
|
||||
assert_eq!(resolved, base.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME));
|
||||
let resolved = resolve_cc_switch_catalog_path(config, &generated).expect("path resolves");
|
||||
assert_eq!(resolved, generated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_path_rejects_user_owned_external_file() {
|
||||
let base = PathBuf::from("/tmp/.codex");
|
||||
let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json");
|
||||
let config = r#"model_catalog_json = "/Users/me/.codex/my-handwritten-catalog.json"
|
||||
"#;
|
||||
assert!(
|
||||
resolve_cc_switch_catalog_path(config, &base).is_none(),
|
||||
resolve_cc_switch_catalog_path(config, &generated).is_none(),
|
||||
"external catalog files should be left alone"
|
||||
);
|
||||
}
|
||||
@@ -4301,109 +4223,36 @@ model = "glm-5"
|
||||
let config_text = r#"model_provider = "custom"
|
||||
model_catalog_json = "cc-switch-model-catalog.json"
|
||||
"#;
|
||||
let base_dir = PathBuf::from("/home/user/.codex");
|
||||
let result = resolve_cc_switch_catalog_path(config_text, &base_dir);
|
||||
let generated_path = PathBuf::from("/home/user/.codex/cc-switch-model-catalog.json");
|
||||
let result = resolve_cc_switch_catalog_path(config_text, &generated_path);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(base_dir.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME)),
|
||||
"relative filename should resolve under base_dir for file I/O"
|
||||
Some(generated_path),
|
||||
"relative filename should resolve to generated_path for file I/O"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_rejects_absolute_path_outside_config_dir() {
|
||||
let config_text = r#"model_catalog_json = "/tmp/secret/cc-switch-model-catalog.json"
|
||||
fn resolve_catalog_ignores_user_owned_relative() {
|
||||
let config_text = r#"model_catalog_json = "my-custom-catalog.json"
|
||||
"#;
|
||||
let base_dir = PathBuf::from("/home/user/.codex");
|
||||
let result = resolve_cc_switch_catalog_path(config_text, &base_dir);
|
||||
let generated_path = PathBuf::from("/home/user/.codex/cc-switch-model-catalog.json");
|
||||
let result = resolve_cc_switch_catalog_path(config_text, &generated_path);
|
||||
assert_eq!(
|
||||
result, None,
|
||||
"absolute path outside ~/.codex must not be accepted"
|
||||
"user-owned catalog should not be claimed by cc-switch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_accepts_absolute_path_inside_config_dir() {
|
||||
let config_text = r#"model_catalog_json = "/home/user/.codex/cc-switch-model-catalog.json"
|
||||
fn set_catalog_json_none_removes_relative_path() {
|
||||
let input = r#"model_catalog_json = "cc-switch-model-catalog.json"
|
||||
"#;
|
||||
let base_dir = PathBuf::from("/home/user/.codex");
|
||||
let result = resolve_cc_switch_catalog_path(config_text, &base_dir);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(base_dir.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME)),
|
||||
"absolute path inside ~/.codex should be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_rejects_traversal_to_parent_directory() {
|
||||
let config_text = r#"model_catalog_json = "../cc-switch-model-catalog.json"
|
||||
"#;
|
||||
let base_dir = PathBuf::from("/home/user/.codex");
|
||||
let result = resolve_cc_switch_catalog_path(config_text, &base_dir);
|
||||
assert_eq!(
|
||||
result, None,
|
||||
"relative traversal outside ~/.codex must not be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_rejects_symlink_escaping_config_dir() {
|
||||
// 词法包含可被符号链接绕过:~/.codex/link -> 外部目录,
|
||||
// "link/cc-switch-model-catalog.json" 词法上在 base 内,真实读取却落到
|
||||
// base 外。canonicalize 之后的二次校验必须拒绝。
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let base_dir = temp.path().join("codex");
|
||||
let outside_dir = temp.path().join("outside");
|
||||
fs::create_dir_all(&base_dir).expect("create base");
|
||||
fs::create_dir_all(&outside_dir).expect("create outside");
|
||||
let escaped_file = outside_dir.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME);
|
||||
fs::write(&escaped_file, r#"{"models":[]}"#).expect("write escaped catalog");
|
||||
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&outside_dir, base_dir.join("link")).expect("symlink");
|
||||
#[cfg(windows)]
|
||||
std::os::windows::fs::symlink_dir(&outside_dir, base_dir.join("link")).expect("symlink");
|
||||
|
||||
let config_text = r#"model_catalog_json = "link/cc-switch-model-catalog.json"
|
||||
"#;
|
||||
let result = resolve_cc_switch_catalog_path(config_text, &base_dir);
|
||||
assert_eq!(
|
||||
result, None,
|
||||
"symlink escaping the config dir must be rejected after canonicalization"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_accepts_real_file_inside_config_dir() {
|
||||
// 存在于 base 内的真实文件:canonical 校验通过后仍应接受
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let base_dir = temp.path().join("codex");
|
||||
fs::create_dir_all(&base_dir).expect("create base");
|
||||
let catalog_file = base_dir.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME);
|
||||
fs::write(&catalog_file, r#"{"models":[]}"#).expect("write catalog");
|
||||
|
||||
let config_text = r#"model_catalog_json = "cc-switch-model-catalog.json"
|
||||
"#;
|
||||
let result = resolve_cc_switch_catalog_path(config_text, &base_dir);
|
||||
let resolved = result.expect("real file inside config dir should be accepted");
|
||||
assert_eq!(
|
||||
resolved.file_name().and_then(|n| n.to_str()),
|
||||
Some(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_limited_string_rejects_oversized_file() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("huge.json");
|
||||
let file = std::fs::File::create(&path).expect("create");
|
||||
file.set_len(MAX_CODEX_CATALOG_BYTES + 1).expect("set_len");
|
||||
|
||||
let result = read_limited_string(&path, MAX_CODEX_CATALOG_BYTES);
|
||||
let result = set_codex_model_catalog_json_field(input, None).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"file larger than MAX_CODEX_CATALOG_BYTES must be rejected"
|
||||
parsed.get("model_catalog_json").is_none(),
|
||||
"None arm should remove relative cc-switch-owned field"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-564
@@ -276,7 +276,7 @@ fn last_lines(text: &str, n: usize) -> String {
|
||||
lines[start..].join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn decode_command_output(bytes: &[u8]) -> String {
|
||||
fn decode_command_output(bytes: &[u8]) -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
decode_windows_command_output(bytes)
|
||||
@@ -1701,29 +1701,15 @@ fn windows_runnable_sibling_for_extensionless_tool(path: &Path) -> Option<std::p
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_tool_command(
|
||||
fn run_windows_tool_version_command(
|
||||
tool_path: &Path,
|
||||
args: &[&str],
|
||||
new_path: &str,
|
||||
) -> std::io::Result<std::process::Output> {
|
||||
use std::process::Command;
|
||||
|
||||
if is_windows_command_script(tool_path) {
|
||||
let path = tool_path.to_string_lossy();
|
||||
let args = args
|
||||
.iter()
|
||||
.map(|arg| windows_cmd_double_quote_arg(arg))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let command = format!(
|
||||
"call {}{}",
|
||||
win_quote_path_for_batch(&path),
|
||||
if args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {args}")
|
||||
}
|
||||
);
|
||||
let command = format!("call {} --version", win_quote_path_for_batch(&path));
|
||||
let mut cmd = Command::new("cmd");
|
||||
return cmd
|
||||
.args(["/D", "/S", "/C"])
|
||||
@@ -1734,20 +1720,12 @@ fn run_windows_tool_command(
|
||||
}
|
||||
|
||||
Command::new(tool_path)
|
||||
.args(args)
|
||||
.arg("--version")
|
||||
.env("PATH", new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_tool_version_command(
|
||||
tool_path: &Path,
|
||||
new_path: &str,
|
||||
) -> std::io::Result<std::process::Output> {
|
||||
run_windows_tool_command(tool_path, &["--version"], new_path)
|
||||
}
|
||||
|
||||
/// 扫描常见路径查找 CLI(PATH 主命令未命中时的兜底单探)。
|
||||
fn scan_cli_version(tool: &str) -> ShellProbe {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -1952,69 +1930,49 @@ fn login_shell_path() -> Option<String> {
|
||||
/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径,
|
||||
/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_path_default(
|
||||
tool: &str,
|
||||
deadline: Option<CommandDeadline>,
|
||||
) -> Result<Option<std::path::PathBuf>, String> {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
|
||||
use std::process::Command;
|
||||
let shell = std::env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|s| is_valid_shell(s))
|
||||
.unwrap_or_else(|| "sh".to_string());
|
||||
let flag = default_flag_for_shell(&shell);
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.arg(flag)
|
||||
let out = Command::new(shell)
|
||||
.arg(flag)
|
||||
.arg(format!("command -v {tool}"))
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
isolate_child_process_group(&mut cmd);
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to locate {tool}: {e}"))?;
|
||||
let out = wait_child_output(child, deadline)?;
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return Ok(None);
|
||||
return None;
|
||||
}
|
||||
let raw = decode_command_output(&out.stdout);
|
||||
// 不能死取第一行:交互式 .zshrc 可能先打印欢迎语(如 "🚀 Welcome back"),
|
||||
// command -v 的真实路径在其后;取第一个 `/` 开头的行才稳。
|
||||
let Some(first) = first_abs_path_line(&raw) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(std::fs::canonicalize(first).ok())
|
||||
let first = first_abs_path_line(&raw)?;
|
||||
std::fs::canonicalize(first).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_path_default(
|
||||
tool: &str,
|
||||
deadline: Option<CommandDeadline>,
|
||||
) -> Result<Option<std::path::PathBuf>, String> {
|
||||
fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let child = Command::new("cmd")
|
||||
use std::process::Command;
|
||||
let out = Command::new("cmd")
|
||||
.args(["/C", &format!("where {tool}")])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to locate {tool}: {e}"))?;
|
||||
let out = wait_child_output(child, deadline)?;
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return Ok(None);
|
||||
return None;
|
||||
}
|
||||
let raw = decode_command_output(&out.stdout);
|
||||
let Some(first) = raw.lines().next().map(str::trim) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let first = raw.lines().next()?.trim();
|
||||
if first.is_empty() {
|
||||
return Ok(None);
|
||||
return None;
|
||||
}
|
||||
let path = Path::new(first);
|
||||
let preferred =
|
||||
windows_runnable_sibling_for_extensionless_tool(path).unwrap_or_else(|| path.to_path_buf());
|
||||
Ok(std::fs::canonicalize(preferred).ok())
|
||||
std::fs::canonicalize(preferred).ok()
|
||||
}
|
||||
|
||||
/// 枚举工具在系统中的所有安装(不短路)。与 `scan_cli_version` 共用
|
||||
@@ -2028,7 +1986,7 @@ fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
|
||||
let current_path = std::env::var_os("PATH")
|
||||
.map(|value| value.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let path_default = resolve_path_default(tool, None).ok().flatten();
|
||||
let path_default = resolve_path_default(tool);
|
||||
|
||||
let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
|
||||
let mut installs: Vec<ToolInstallation> = Vec::new();
|
||||
@@ -2644,468 +2602,6 @@ fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> {
|
||||
})
|
||||
}
|
||||
|
||||
fn locate_default_tool(
|
||||
tool: &str,
|
||||
deadline: Option<CommandDeadline>,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
let path_default = resolve_path_default(tool, deadline)?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
for dir in build_tool_search_paths(tool) {
|
||||
for candidate in tool_executable_candidates(tool, &dir) {
|
||||
if !candidate.exists() {
|
||||
continue;
|
||||
}
|
||||
let real = std::fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone());
|
||||
if path_default.as_ref() == Some(&real) {
|
||||
return Ok(candidate);
|
||||
}
|
||||
if seen.insert(real) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = path_default {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
match candidates.as_slice() {
|
||||
[only] => Ok(only.clone()),
|
||||
[] => Err(format!("{tool} is not installed")),
|
||||
_ => Err(format!(
|
||||
"{tool} is installed but its default installation is ambiguous"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CommandDeadline {
|
||||
expires_at: std::time::Instant,
|
||||
limit: std::time::Duration,
|
||||
}
|
||||
|
||||
impl CommandDeadline {
|
||||
fn from_timeout(timeout: Option<std::time::Duration>) -> Option<Self> {
|
||||
timeout.map(|limit| Self {
|
||||
expires_at: std::time::Instant::now() + limit,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
fn remaining(self) -> Result<std::time::Duration, String> {
|
||||
self.expires_at
|
||||
.checked_duration_since(std::time::Instant::now())
|
||||
.filter(|remaining| !remaining.is_zero())
|
||||
.ok_or_else(|| self.timeout_error())
|
||||
}
|
||||
|
||||
fn timeout_error(self) -> String {
|
||||
format!("Command timed out after {}s", self.limit.as_secs())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn terminate_child_tree(child: &mut std::process::Child) -> bool {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let status = Command::new("taskkill")
|
||||
.args(["/PID", &child.id().to_string(), "/T", "/F"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
matches!(status, Ok(status) if status.success()) || child.kill().is_ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn terminate_child_tree(child: &mut std::process::Child) -> bool {
|
||||
let process_group = -(child.id() as libc::pid_t);
|
||||
// SAFETY: runtime commands are placed in a dedicated process group before spawn.
|
||||
(unsafe { libc::kill(process_group, libc::SIGKILL) == 0 }) || child.kill().is_ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn isolate_child_process_group(cmd: &mut std::process::Command) {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
cmd.process_group(0);
|
||||
}
|
||||
|
||||
fn wait_child_output(
|
||||
mut child: std::process::Child,
|
||||
deadline: Option<CommandDeadline>,
|
||||
) -> Result<std::process::Output, String> {
|
||||
use std::io::Read;
|
||||
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
|
||||
let stdout_handle = stdout_pipe.map(|mut pipe| {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
buf
|
||||
})
|
||||
});
|
||||
let stderr_handle = stderr_pipe.map(|mut pipe| {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
buf
|
||||
})
|
||||
});
|
||||
|
||||
let status = match deadline {
|
||||
None => child
|
||||
.wait()
|
||||
.map_err(|e| format!("Failed to wait for command: {e}"))?,
|
||||
Some(deadline) => {
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) => {
|
||||
let remaining = match deadline.remaining() {
|
||||
Ok(remaining) => remaining,
|
||||
Err(error) => {
|
||||
if terminate_child_tree(&mut child) {
|
||||
let _ = child.wait();
|
||||
}
|
||||
// Do not join pipe readers on timeout. If tree termination fails,
|
||||
// a descendant may still own the write handle and never produce EOF.
|
||||
drop(stdout_handle);
|
||||
drop(stderr_handle);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
std::thread::sleep(std::cmp::min(
|
||||
std::time::Duration::from_millis(50),
|
||||
remaining,
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
if terminate_child_tree(&mut child) {
|
||||
let _ = child.wait();
|
||||
}
|
||||
return Err(format!("Failed to wait for command: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(deadline) = deadline {
|
||||
while stdout_handle
|
||||
.as_ref()
|
||||
.is_some_and(|handle| !handle.is_finished())
|
||||
|| stderr_handle
|
||||
.as_ref()
|
||||
.is_some_and(|handle| !handle.is_finished())
|
||||
{
|
||||
let remaining = match deadline.remaining() {
|
||||
Ok(remaining) => remaining,
|
||||
Err(error) => {
|
||||
let _ = terminate_child_tree(&mut child);
|
||||
drop(stdout_handle);
|
||||
drop(stderr_handle);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
std::thread::sleep(std::cmp::min(
|
||||
std::time::Duration::from_millis(50),
|
||||
remaining,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let stdout = stdout_handle
|
||||
.map(|handle| handle.join().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let stderr = stderr_handle
|
||||
.map(|handle| handle.join().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(std::process::Output {
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_extra_env(cmd: &mut std::process::Command, extra_env: &[(&str, String)]) {
|
||||
for (key, value) in extra_env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_detected_tool_command_with_timeout(
|
||||
tool: &str,
|
||||
args: &[&str],
|
||||
timeout: Option<std::time::Duration>,
|
||||
extra_env: &[(&str, String)],
|
||||
working_dir: &Path,
|
||||
) -> Result<std::process::Output, String> {
|
||||
if !VALID_TOOLS.contains(&tool) {
|
||||
return Err(format!("Unsupported tool: {tool}"));
|
||||
}
|
||||
if args.iter().any(|arg| {
|
||||
arg.is_empty()
|
||||
|| !arg
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
}) {
|
||||
return Err("Invalid tool command arguments".to_string());
|
||||
}
|
||||
|
||||
let deadline = CommandDeadline::from_timeout(timeout);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
return run_wsl_tool_command(tool, args, &distro, deadline, extra_env, working_dir);
|
||||
}
|
||||
|
||||
// Runtime execution only needs the default entry point. Full installation
|
||||
// enumeration runs `--version` for every candidate and belongs to diagnostics.
|
||||
let tool_path = locate_default_tool(tool, deadline)?;
|
||||
let dir = tool_path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Invalid {tool} executable path"))?;
|
||||
let current_path = std::env::var_os("PATH")
|
||||
.map(|value| value.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
run_windows_tool_command_capture(
|
||||
&tool_path,
|
||||
args,
|
||||
&format!("{};{current_path}", dir.display()),
|
||||
deadline,
|
||||
extra_env,
|
||||
working_dir,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut cmd = Command::new(&tool_path);
|
||||
cmd.args(args)
|
||||
.env("PATH", format!("{}:{current_path}", dir.display()))
|
||||
.current_dir(working_dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
apply_extra_env(&mut cmd, extra_env);
|
||||
isolate_child_process_group(&mut cmd);
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to run {tool}: {e}"))?;
|
||||
wait_child_output(child, deadline)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_tool_command_capture(
|
||||
tool_path: &Path,
|
||||
args: &[&str],
|
||||
new_path: &str,
|
||||
deadline: Option<CommandDeadline>,
|
||||
extra_env: &[(&str, String)],
|
||||
working_dir: &Path,
|
||||
) -> Result<std::process::Output, String> {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut cmd = if is_windows_command_script(tool_path) {
|
||||
let path = tool_path.to_string_lossy();
|
||||
let args = args
|
||||
.iter()
|
||||
.map(|arg| windows_cmd_double_quote_arg(arg))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let command = format!(
|
||||
"call {}{}",
|
||||
win_quote_path_for_batch(&path),
|
||||
if args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {args}")
|
||||
}
|
||||
);
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C"])
|
||||
.raw_arg(&command)
|
||||
.env("PATH", new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = Command::new(tool_path);
|
||||
cmd.args(args)
|
||||
.env("PATH", new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
};
|
||||
|
||||
apply_extra_env(&mut cmd, extra_env);
|
||||
cmd.current_dir(working_dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to run tool: {e}"))?;
|
||||
wait_child_output(child, deadline)
|
||||
}
|
||||
|
||||
/// Convert `\\wsl$\Distro\home\user\...` / `\\wsl.localhost\...` to a Linux path.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_unc_path_to_linux(path: &Path) -> Option<String> {
|
||||
use std::path::{Component, Prefix};
|
||||
|
||||
let mut components = path.components();
|
||||
let Component::Prefix(prefix) = components.next()? else {
|
||||
return None;
|
||||
};
|
||||
match prefix.kind() {
|
||||
Prefix::UNC(server, _share) | Prefix::VerbatimUNC(server, _share) => {
|
||||
let server_name = server.to_string_lossy();
|
||||
if !(server_name.eq_ignore_ascii_case("wsl$")
|
||||
|| server_name.eq_ignore_ascii_case("wsl.localhost"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
let mut linux = String::new();
|
||||
for component in components {
|
||||
match component {
|
||||
Component::RootDir => {}
|
||||
Component::Normal(part) => {
|
||||
linux.push('/');
|
||||
linux.push_str(&part.to_string_lossy());
|
||||
}
|
||||
Component::CurDir | Component::ParentDir | Component::Prefix(_) => return None,
|
||||
}
|
||||
}
|
||||
if linux.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(linux)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_wsl_env_argv(extra_env: &[(&str, String)]) -> Result<Vec<String>, String> {
|
||||
let mut env_argv = Vec::new();
|
||||
for (key, value) in extra_env {
|
||||
if key.is_empty()
|
||||
|| key.contains('=')
|
||||
|| key.chars().any(|c| c.is_whitespace() || c.is_control())
|
||||
{
|
||||
return Err(format!("invalid env for {key}"));
|
||||
}
|
||||
|
||||
let linux_value = if *key == "OPENCODE_CONFIG_DIR" {
|
||||
let Some(value) = wsl_unc_path_to_linux(Path::new(value)) else {
|
||||
continue;
|
||||
};
|
||||
value
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
if linux_value.chars().any(char::is_control) {
|
||||
return Err(format!("invalid env for {key}"));
|
||||
}
|
||||
env_argv.push(format!("{key}={linux_value}"));
|
||||
}
|
||||
Ok(env_argv)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_wsl_tool_command(
|
||||
tool: &str,
|
||||
args: &[&str],
|
||||
deadline: Option<CommandDeadline>,
|
||||
) -> Result<String, String> {
|
||||
let invocation = std::iter::once(tool)
|
||||
.chain(args.iter().copied())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let command = format!(
|
||||
"for flag in -lic -lc -c; do if \"${{SHELL:-sh}}\" \"$flag\" 'command -v {tool}' >/dev/null 2>&1; then exec \"${{SHELL:-sh}}\" \"$flag\" '{invocation}'; fi; done; exit 127"
|
||||
);
|
||||
|
||||
let Some(deadline) = deadline else {
|
||||
return Ok(command);
|
||||
};
|
||||
let remaining = deadline.remaining()?;
|
||||
let timeout_arg = format!("{:.3}s", remaining.as_secs_f64());
|
||||
Ok(format!(
|
||||
"command -v timeout >/dev/null 2>&1 || {{ echo 'timeout is required for bounded CLI execution' >&2; exit 127; }}; exec timeout --signal=TERM --kill-after=1s {timeout_arg} sh -c {}",
|
||||
shell_single_quote(&command)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_wsl_tool_command(
|
||||
tool: &str,
|
||||
args: &[&str],
|
||||
distro: &str,
|
||||
deadline: Option<CommandDeadline>,
|
||||
extra_env: &[(&str, String)],
|
||||
working_dir: &Path,
|
||||
) -> Result<std::process::Output, String> {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
if !is_valid_wsl_distro_name(distro) {
|
||||
return Err(format!("[WSL:{distro}] invalid distro name"));
|
||||
}
|
||||
|
||||
let command = build_wsl_tool_command(tool, args, deadline)?;
|
||||
let linux_working_dir = wsl_unc_path_to_linux(working_dir)
|
||||
.ok_or_else(|| format!("[WSL:{distro}] invalid working directory"))?;
|
||||
let env_argv = build_wsl_env_argv(extra_env).map_err(|e| format!("[WSL:{distro}] {e}"))?;
|
||||
|
||||
let mut cmd = Command::new("wsl.exe");
|
||||
cmd.arg("-d")
|
||||
.arg(distro)
|
||||
.arg("--cd")
|
||||
.arg(linux_working_dir)
|
||||
.arg("--");
|
||||
if !env_argv.is_empty() {
|
||||
cmd.arg("env");
|
||||
for item in &env_argv {
|
||||
cmd.arg(item);
|
||||
}
|
||||
}
|
||||
cmd.args(["sh", "-c", &command])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("[WSL:{distro}] failed to run {tool}: {e}"))?;
|
||||
let output = wait_child_output(child, deadline).map_err(|e| {
|
||||
if e.starts_with("Command timed out") {
|
||||
format!("[WSL:{distro}] {e}")
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
if output.status.code() == Some(124) {
|
||||
return Err(format!(
|
||||
"[WSL:{distro}] {}",
|
||||
deadline
|
||||
.map(CommandDeadline::timeout_error)
|
||||
.unwrap_or_else(|| "Command timed out".to_string())
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// 基于已枚举的安装列表生成锚定升级命令(复用 enumerate 结果,避免二次探测)。
|
||||
/// 读取 enumerate 时已 canonicalize 写入的 `inst.real`,**不再二次 canonicalize**——
|
||||
/// 既消除冗余 syscall,也闭合"enumerate 与 anchor 看到同一真身"的一致性边界
|
||||
@@ -4270,43 +3766,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn wsl_env_allows_spaces_in_unc_config_path() {
|
||||
let extra_env = [
|
||||
(
|
||||
"OPENCODE_CONFIG_DIR",
|
||||
r"\\wsl$\Ubuntu\home\Jane Doe\.config\opencode".to_string(),
|
||||
),
|
||||
("OPENCODE_DISABLE_PROJECT_CONFIG", "true".to_string()),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
build_wsl_env_argv(&extra_env).unwrap(),
|
||||
vec![
|
||||
"OPENCODE_CONFIG_DIR=/home/Jane Doe/.config/opencode".to_string(),
|
||||
"OPENCODE_DISABLE_PROJECT_CONFIG=true".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn wsl_env_skips_host_config_path() {
|
||||
let extra_env = [
|
||||
(
|
||||
"OPENCODE_CONFIG_DIR",
|
||||
r"C:\Users\Jane Doe\.config\opencode".to_string(),
|
||||
),
|
||||
("OPENCODE_DISABLE_PROJECT_CONFIG", "true".to_string()),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
build_wsl_env_argv(&extra_env).unwrap(),
|
||||
vec!["OPENCODE_DISABLE_PROJECT_CONFIG=true".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_test_executable(path: &Path, executable: bool) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -3,89 +3,6 @@
|
||||
//! 提供 Tauri 命令,供前端在供应商表单中获取可用模型列表。
|
||||
|
||||
use crate::services::model_fetch::{self, FetchedModel};
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenCodeModelRef {
|
||||
pub provider_id: String,
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
const OPENCODE_MODELS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// 获取 OpenCode 当前运行时可用的模型。
|
||||
///
|
||||
/// 复用工具更新页的 CLI 定位逻辑执行 `opencode models`,因此会包含 OpenCode
|
||||
/// 已加载的 OAuth 模型与 Zen 免费模型,而不是只读取 opencode.json。
|
||||
#[tauri::command]
|
||||
pub async fn get_opencode_models() -> Result<Vec<OpenCodeModelRef>, String> {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
// Align runtime discovery with the OpenCode config directory that
|
||||
// cc-switch already uses for live read/write (settings override included).
|
||||
let config_dir = crate::opencode_config::get_opencode_dir();
|
||||
let config_dir_env = config_dir.to_string_lossy().into_owned();
|
||||
let extra_env = [
|
||||
("OPENCODE_CONFIG_DIR", config_dir_env),
|
||||
("OPENCODE_DISABLE_PROJECT_CONFIG", "true".to_string()),
|
||||
];
|
||||
let output = super::misc::run_detected_tool_command_with_timeout(
|
||||
"opencode",
|
||||
&["models"],
|
||||
Some(OPENCODE_MODELS_TIMEOUT),
|
||||
&extra_env,
|
||||
&config_dir,
|
||||
)?;
|
||||
if !output.status.success() {
|
||||
let stderr = super::misc::decode_command_output(&output.stderr);
|
||||
let stdout = super::misc::decode_command_output(&output.stdout);
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
stderr.trim()
|
||||
};
|
||||
return Err(if detail.is_empty() {
|
||||
"Failed to load OpenCode models".to_string()
|
||||
} else {
|
||||
format!("Failed to load OpenCode models: {detail}")
|
||||
});
|
||||
}
|
||||
|
||||
Ok(parse_opencode_models(&super::misc::decode_command_output(
|
||||
&output.stdout,
|
||||
)))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("OpenCode model discovery task failed: {e}"))?
|
||||
}
|
||||
|
||||
fn parse_opencode_models(output: &str) -> Vec<OpenCodeModelRef> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (provider_id, model_id) = line.trim().split_once('/')?;
|
||||
if provider_id.is_empty()
|
||||
|| model_id.is_empty()
|
||||
|| !provider_id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
|| model_id
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || c.is_control())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((provider_id.to_string(), model_id.to_string()))
|
||||
})
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.map(|(provider_id, model_id)| OpenCodeModelRef {
|
||||
provider_id,
|
||||
model_id,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取供应商的可用模型列表
|
||||
///
|
||||
@@ -112,35 +29,3 @@ pub async fn fetch_models_for_config(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_opencode_models, OpenCodeModelRef};
|
||||
|
||||
#[test]
|
||||
fn parses_sorts_and_deduplicates_models() {
|
||||
assert_eq!(
|
||||
parse_opencode_models(
|
||||
"openrouter/vendor/model\nopencode/free-model\ninvalid\nopencode/free-model\n"
|
||||
),
|
||||
vec![
|
||||
OpenCodeModelRef {
|
||||
provider_id: "opencode".to_string(),
|
||||
model_id: "free-model".to_string(),
|
||||
},
|
||||
OpenCodeModelRef {
|
||||
provider_id: "openrouter".to_string(),
|
||||
model_id: "vendor/model".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_malformed_output_lines() {
|
||||
assert!(parse_opencode_models(
|
||||
"notice: loading models\n/model\nprovider/\nbad provider/model\nprovider/bad model\nprovider/bad\u{1b}[0m\n"
|
||||
)
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,26 +90,6 @@ fn path_eq_lexical(left: &Path, right: &Path) -> bool {
|
||||
comparable_path_key(left) == comparable_path_key(right)
|
||||
}
|
||||
|
||||
/// Returns true when `path` is lexically contained within `base`.
|
||||
///
|
||||
/// Both paths are normalized lexically (without hitting the filesystem), so
|
||||
/// this works for non-existent paths. It is **not** a symlink defense: a
|
||||
/// symlink inside `base` can still lead a resolved path outside it. Callers
|
||||
/// that go on to open the file must canonicalize the existing path and
|
||||
/// re-verify containment (see `resolve_cc_switch_catalog_path`).
|
||||
/// On Windows the comparison is case-insensitive.
|
||||
pub(crate) fn path_is_within(base: &Path, path: &Path) -> bool {
|
||||
let base_key = comparable_path_key(base);
|
||||
let path_key = comparable_path_key(path);
|
||||
|
||||
if path_key == base_key {
|
||||
return true;
|
||||
}
|
||||
|
||||
let prefix = format!("{base_key}/");
|
||||
path_key.starts_with(&prefix)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn derive_wsl_default_mcp_path(dir: &Path) -> Option<PathBuf> {
|
||||
use std::path::Prefix;
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
//!
|
||||
//! 提供 MCP 服务器的 CRUD 操作。
|
||||
|
||||
use crate::app_config::{AppType, McpApps, McpServer};
|
||||
use crate::app_config::{McpApps, McpServer};
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::{params, OptionalExtension, Row};
|
||||
use rusqlite::params;
|
||||
|
||||
const MCP_SERVER_SELECT: &str =
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes FROM mcp_servers";
|
||||
impl Database {
|
||||
/// 获取所有 MCP 服务器
|
||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
|
||||
FROM mcp_servers
|
||||
ORDER BY name ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
fn row_to_mcp_server(row: &Row<'_>) -> rusqlite::Result<(String, McpServer)> {
|
||||
let server_iter = stmt
|
||||
.query_map([], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let server_config_str: String = row.get(2)?;
|
||||
@@ -49,18 +57,7 @@ fn row_to_mcp_server(row: &Row<'_>) -> rusqlite::Result<(String, McpServer)> {
|
||||
tags,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 获取所有 MCP 服务器
|
||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("{MCP_SERVER_SELECT} ORDER BY name ASC, id ASC"))
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let server_iter = stmt
|
||||
.query_map([], row_to_mcp_server)
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut servers = IndexMap::new();
|
||||
@@ -71,49 +68,6 @@ impl Database {
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
/// Atomically update one application's flag and return the authoritative row.
|
||||
///
|
||||
/// The update and read share the same connection lock, so concurrent toggles
|
||||
/// for different applications cannot overwrite one another through a stale
|
||||
/// whole-row snapshot.
|
||||
pub fn update_mcp_server_app_enabled(
|
||||
&self,
|
||||
id: &str,
|
||||
app: &AppType,
|
||||
enabled: bool,
|
||||
) -> Result<Option<McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let column = match app {
|
||||
AppType::Claude => Some("enabled_claude"),
|
||||
AppType::Codex => Some("enabled_codex"),
|
||||
AppType::Gemini => Some("enabled_gemini"),
|
||||
AppType::GrokBuild => Some("enabled_grokbuild"),
|
||||
AppType::OpenCode => Some("enabled_opencode"),
|
||||
AppType::Hermes => Some("enabled_hermes"),
|
||||
// These applications intentionally have no MCP flag in the SSOT.
|
||||
AppType::ClaudeDesktop | AppType::OpenClaw => None,
|
||||
};
|
||||
|
||||
if let Some(column) = column {
|
||||
// `column` comes exclusively from the fixed allow-list above.
|
||||
let sql = format!("UPDATE mcp_servers SET {column} = ?1 WHERE id = ?2");
|
||||
let affected = conn
|
||||
.execute(&sql, params![enabled, id])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if affected == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
conn.query_row(
|
||||
&format!("{MCP_SERVER_SELECT} WHERE id = ?1"),
|
||||
params![id],
|
||||
|row| row_to_mcp_server(row).map(|(_, server)| server),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(e.to_string()))
|
||||
}
|
||||
|
||||
/// 保存 MCP 服务器
|
||||
pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
@@ -153,116 +107,3 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
|
||||
fn test_server() -> McpServer {
|
||||
McpServer {
|
||||
id: "shared-server".to_string(),
|
||||
name: "Shared Server".to_string(),
|
||||
server: json!({ "command": "echo", "args": ["hello"] }),
|
||||
apps: McpApps {
|
||||
gemini: true,
|
||||
..McpApps::default()
|
||||
},
|
||||
description: Some("description".to_string()),
|
||||
homepage: Some("https://example.com".to_string()),
|
||||
docs: None,
|
||||
tags: vec!["shared".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_flag_updates_preserve_other_flags_and_return_authoritative_row() {
|
||||
let db = Database::memory().expect("create memory db");
|
||||
db.save_mcp_server(&test_server()).expect("seed server");
|
||||
|
||||
let after_claude = db
|
||||
.update_mcp_server_app_enabled("shared-server", &AppType::Claude, true)
|
||||
.expect("enable Claude")
|
||||
.expect("server exists");
|
||||
assert!(after_claude.apps.claude);
|
||||
assert!(after_claude.apps.gemini);
|
||||
|
||||
let after_codex = db
|
||||
.update_mcp_server_app_enabled("shared-server", &AppType::Codex, true)
|
||||
.expect("enable Codex")
|
||||
.expect("server exists");
|
||||
assert!(after_codex.apps.claude);
|
||||
assert!(after_codex.apps.codex);
|
||||
assert!(after_codex.apps.gemini);
|
||||
assert_eq!(after_codex.description.as_deref(), Some("description"));
|
||||
assert_eq!(after_codex.tags, vec!["shared"]);
|
||||
|
||||
let stored = db
|
||||
.get_all_mcp_servers()
|
||||
.expect("read servers")
|
||||
.shift_remove("shared-server")
|
||||
.expect("stored server");
|
||||
assert_eq!(stored.apps, after_codex.apps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_app_flag_updates_do_not_lose_each_other() {
|
||||
let db = Arc::new(Database::memory().expect("create memory db"));
|
||||
db.save_mcp_server(&test_server()).expect("seed server");
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
|
||||
let handles = [AppType::Claude, AppType::Codex].map(|app| {
|
||||
let db = Arc::clone(&db);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
db.update_mcp_server_app_enabled("shared-server", &app, true)
|
||||
.expect("update app flag")
|
||||
.expect("server exists");
|
||||
})
|
||||
});
|
||||
|
||||
barrier.wait();
|
||||
for handle in handles {
|
||||
handle.join().expect("join app toggle");
|
||||
}
|
||||
|
||||
let stored = db
|
||||
.get_all_mcp_servers()
|
||||
.expect("read servers")
|
||||
.shift_remove("shared-server")
|
||||
.expect("stored server");
|
||||
assert!(stored.apps.claude);
|
||||
assert!(stored.apps.codex);
|
||||
assert!(stored.apps.gemini);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_flag_update_does_not_insert_a_missing_server() {
|
||||
let db = Database::memory().expect("create memory db");
|
||||
|
||||
let updated = db
|
||||
.update_mcp_server_app_enabled("missing", &AppType::Claude, true)
|
||||
.expect("update missing server");
|
||||
|
||||
assert!(updated.is_none());
|
||||
assert!(db.get_all_mcp_servers().expect("read servers").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_mcp_apps_keep_the_existing_noop_semantics() {
|
||||
let db = Database::memory().expect("create memory db");
|
||||
let original = test_server();
|
||||
db.save_mcp_server(&original).expect("seed server");
|
||||
|
||||
for app in [AppType::ClaudeDesktop, AppType::OpenClaw] {
|
||||
let returned = db
|
||||
.update_mcp_server_app_enabled("shared-server", &app, true)
|
||||
.expect("toggle unsupported app")
|
||||
.expect("server exists");
|
||||
assert_eq!(returned.apps, original.apps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,46 +138,6 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 仅更新已安装 Skill 的元数据,不修改各应用的启用状态。
|
||||
///
|
||||
/// 与 [`Self::save_skill`] 不同,本方法不会插入缺失记录。更新操作可能在网络
|
||||
/// 下载期间与启用状态切换或卸载并发发生,因此调用方必须保留数据库中的
|
||||
/// `enabled_*` 字段,并在记录已被删除时停止后续处理。
|
||||
pub fn update_skill_metadata(&self, skill: &InstalledSkill) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills
|
||||
SET name = ?1,
|
||||
description = ?2,
|
||||
directory = ?3,
|
||||
repo_owner = ?4,
|
||||
repo_name = ?5,
|
||||
repo_branch = ?6,
|
||||
readme_url = ?7,
|
||||
installed_at = ?8,
|
||||
content_hash = ?9,
|
||||
updated_at = ?10
|
||||
WHERE id = ?11 AND installed_at = ?12",
|
||||
params![
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.directory,
|
||||
skill.repo_owner,
|
||||
skill.repo_name,
|
||||
skill.repo_branch,
|
||||
skill.readme_url,
|
||||
skill.installed_at,
|
||||
skill.content_hash,
|
||||
skill.updated_at,
|
||||
skill.id,
|
||||
skill.installed_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
/// 删除 Skill
|
||||
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
@@ -302,103 +262,3 @@ impl Database {
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_config::AppType;
|
||||
|
||||
fn skill(id: &str, name: &str, apps: SkillApps) -> InstalledSkill {
|
||||
InstalledSkill {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: Some(format!("{name} description")),
|
||||
directory: format!("{name}-directory"),
|
||||
repo_owner: Some("owner".to_string()),
|
||||
repo_name: Some("repo".to_string()),
|
||||
repo_branch: Some("main".to_string()),
|
||||
readme_url: Some(format!("https://example.com/{name}")),
|
||||
apps,
|
||||
installed_at: 1,
|
||||
content_hash: Some(format!("{name}-hash")),
|
||||
updated_at: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_skill_metadata_preserves_enabled_apps() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let installed_apps = SkillApps::only(&AppType::Codex);
|
||||
let original = skill("owner/repo:skill", "original", installed_apps.clone());
|
||||
db.save_skill(&original).expect("seed skill");
|
||||
|
||||
let mut candidate = skill(&original.id, "updated", SkillApps::only(&AppType::Claude));
|
||||
candidate.repo_branch = Some("next".to_string());
|
||||
candidate.updated_at = 42;
|
||||
|
||||
assert!(db
|
||||
.update_skill_metadata(&candidate)
|
||||
.expect("update metadata"));
|
||||
|
||||
let stored = db
|
||||
.get_installed_skill(&original.id)
|
||||
.expect("query skill")
|
||||
.expect("skill remains installed");
|
||||
assert_eq!(stored.name, candidate.name);
|
||||
assert_eq!(stored.description, candidate.description);
|
||||
assert_eq!(stored.directory, candidate.directory);
|
||||
assert_eq!(stored.repo_branch, candidate.repo_branch);
|
||||
assert_eq!(stored.readme_url, candidate.readme_url);
|
||||
assert_eq!(stored.content_hash, candidate.content_hash);
|
||||
assert_eq!(stored.updated_at, candidate.updated_at);
|
||||
assert_eq!(stored.apps, installed_apps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_skill_metadata_does_not_insert_missing_skill() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let candidate = skill(
|
||||
"owner/repo:missing",
|
||||
"missing",
|
||||
SkillApps::only(&AppType::Claude),
|
||||
);
|
||||
|
||||
assert!(!db
|
||||
.update_skill_metadata(&candidate)
|
||||
.expect("missing update is not an error"));
|
||||
assert!(db
|
||||
.get_installed_skill(&candidate.id)
|
||||
.expect("query skill")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_skill_metadata_does_not_touch_reinstalled_generation() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let stale_update = skill(
|
||||
"owner/repo:skill",
|
||||
"stale-update",
|
||||
SkillApps::only(&AppType::Claude),
|
||||
);
|
||||
|
||||
let mut reinstalled = skill(
|
||||
&stale_update.id,
|
||||
"reinstalled",
|
||||
SkillApps::only(&AppType::Gemini),
|
||||
);
|
||||
reinstalled.installed_at = stale_update.installed_at + 1;
|
||||
db.save_skill(&reinstalled).expect("seed reinstalled skill");
|
||||
|
||||
assert!(!db
|
||||
.update_skill_metadata(&stale_update)
|
||||
.expect("stale generation update is not an error"));
|
||||
|
||||
let stored = db
|
||||
.get_installed_skill(&reinstalled.id)
|
||||
.expect("query skill")
|
||||
.expect("reinstalled generation remains");
|
||||
assert_eq!(stored.name, reinstalled.name);
|
||||
assert_eq!(stored.installed_at, reinstalled.installed_at);
|
||||
assert_eq!(stored.apps, reinstalled.apps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2237,7 +2237,6 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
// Qwen 系列 (阿里巴巴)
|
||||
("qwen3.8-max", "Qwen3.8 Max", "2", "6", "0.25", "2.50"),
|
||||
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
|
||||
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
|
||||
(
|
||||
|
||||
@@ -1417,7 +1417,6 @@ pub fn run() {
|
||||
commands::apply_profile,
|
||||
// model list fetch (OpenAI-compatible /v1/models)
|
||||
commands::fetch_models_for_config,
|
||||
commands::get_opencode_models,
|
||||
// ours: endpoint speed test + custom endpoint management
|
||||
commands::test_api_endpoints,
|
||||
commands::get_custom_endpoints,
|
||||
|
||||
@@ -33,29 +33,13 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw => "AGENTS.md",
|
||||
AppType::Hermes => "SOUL.md",
|
||||
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hermes_prompt_file_uses_soul_md() {
|
||||
let path = prompt_file_path(&AppType::Hermes).expect("Hermes prompt path");
|
||||
|
||||
assert_eq!(
|
||||
path.file_name().and_then(|name| name.to_str()),
|
||||
Some("SOUL.md")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_base_dir_with_fallback(
|
||||
primary_path: PathBuf,
|
||||
fallback_dir: &str,
|
||||
|
||||
@@ -87,17 +87,6 @@ impl Provider {
|
||||
|| self.claude_base_url_contains("chatgpt.com/backend-api/codex")
|
||||
}
|
||||
|
||||
/// Whether the provider form's "auth field" was explicitly set to
|
||||
/// ANTHROPIC_API_KEY. The form only persists `meta.apiKeyField` for the
|
||||
/// non-default choice, so `None` means the default ANTHROPIC_AUTH_TOKEN.
|
||||
pub fn claude_uses_api_key_field(&self) -> bool {
|
||||
self.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.api_key_field.as_deref())
|
||||
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> Option<&str> {
|
||||
self.meta.as_ref().and_then(|m| m.provider_type.as_deref())
|
||||
}
|
||||
|
||||
@@ -27,114 +27,55 @@ fn is_single_supported(coding: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// 解压失败原因。把「输出超预算」与「数据损坏」区分开:前者是安全拒绝信号,
|
||||
/// 响应侧调用方应据此拒绝响应(502),而不是当成普通解压失败静默回退。
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DecompressError {
|
||||
/// 底层解码失败(数据损坏 / 格式不符)。
|
||||
Io(std::io::Error),
|
||||
/// 解压输出超过 `limit` 字节即中止;此时真实输出大小未知,只会大于 limit。
|
||||
TooLarge { limit: usize },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DecompressError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(e) => write!(f, "{e}"),
|
||||
Self::TooLarge { limit } => write!(f, "解压输出超过上限 {limit} 字节"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DecompressError {}
|
||||
|
||||
impl From<std::io::Error> for DecompressError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DecompressError> for std::io::Error {
|
||||
fn from(e: DecompressError) -> Self {
|
||||
match e {
|
||||
DecompressError::Io(e) => e,
|
||||
DecompressError::TooLarge { limit } => {
|
||||
std::io::Error::other(format!("decompressed body exceeds {limit} bytes"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从解码器读取解压输出,最多 `max_bytes`;一旦输出超过预算立即中止读取并返回
|
||||
/// [`DecompressError::TooLarge`] —— 压缩炸弹在预算耗尽处被截停,而不是先在内存里
|
||||
/// 完整展开再比较大小。
|
||||
fn read_with_output_limit<R: Read>(
|
||||
reader: R,
|
||||
max_bytes: usize,
|
||||
) -> Result<Vec<u8>, DecompressError> {
|
||||
// saturating_add:无界调用(max_bytes = usize::MAX)时预算保持 usize::MAX
|
||||
let budget = max_bytes.saturating_add(1) as u64;
|
||||
let mut limited = reader.take(budget);
|
||||
let mut out = Vec::new();
|
||||
limited.read_to_end(&mut out)?;
|
||||
if out.len() > max_bytes {
|
||||
return Err(DecompressError::TooLarge { limit: max_bytes });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 解压单个 content-coding,输出上限 `max_output_bytes`。未知编码返回 `Ok(None)`。
|
||||
fn decompress_single(
|
||||
coding: &str,
|
||||
body: &[u8],
|
||||
max_output_bytes: usize,
|
||||
) -> Result<Option<Vec<u8>>, DecompressError> {
|
||||
/// 解压单个 content-coding。未知编码返回 `Ok(None)`。
|
||||
fn decompress_single(coding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
match coding {
|
||||
"gzip" | "x-gzip" => {
|
||||
let decoder = flate2::read::GzDecoder::new(body);
|
||||
Ok(Some(read_with_output_limit(decoder, max_output_bytes)?))
|
||||
let mut decoder = flate2::read::GzDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
"deflate" => {
|
||||
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游 / 客户端发 raw deflate 流。
|
||||
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规来源必然解压失败,
|
||||
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
|
||||
let zlib = flate2::read::ZlibDecoder::new(body);
|
||||
match read_with_output_limit(zlib, max_output_bytes) {
|
||||
Ok(decompressed) => Ok(Some(decompressed)),
|
||||
let mut decompressed = Vec::new();
|
||||
let mut zlib = flate2::read::ZlibDecoder::new(body);
|
||||
match zlib.read_to_end(&mut decompressed) {
|
||||
Ok(_) => Ok(Some(decompressed)),
|
||||
Err(zlib_err) => {
|
||||
// TooLarge 也要回退:raw 流被误判为 zlib 时可能在预算处截停,
|
||||
// 回退后若真是炸弹,raw 解码同样会触发 TooLarge。
|
||||
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
|
||||
let raw = flate2::read::DeflateDecoder::new(body);
|
||||
Ok(Some(read_with_output_limit(raw, max_output_bytes)?))
|
||||
let mut decompressed = Vec::new();
|
||||
let mut raw = flate2::read::DeflateDecoder::new(body);
|
||||
raw.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
}
|
||||
}
|
||||
"br" => {
|
||||
let decoder = brotli::Decompressor::new(std::io::Cursor::new(body), 4096);
|
||||
Ok(Some(read_with_output_limit(decoder, max_output_bytes)?))
|
||||
let mut decompressed = Vec::new();
|
||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
"zstd" | "zst" => {
|
||||
// Codex 登录态对请求体启用 zstd(Compression::Zstd);上游也可能 zstd 压缩响应。
|
||||
let decoder = zstd::stream::read::Decoder::new(std::io::Cursor::new(body))?;
|
||||
Ok(Some(read_with_output_limit(decoder, max_output_bytes)?))
|
||||
let decompressed = zstd::stream::decode_all(std::io::Cursor::new(body))?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 content-encoding 解压 body 字节,支持堆叠编码(如 `gzip, zstd`),
|
||||
/// 且每个 coding 的解压输出(含堆叠编码的中间产物)都受 `max_output_bytes`
|
||||
/// 限制,超限即中止并返回 [`DecompressError::TooLarge`],用于防御响应侧压缩炸弹。
|
||||
/// 根据 content-encoding 解压 body 字节,支持堆叠编码(如 `gzip, zstd`)。
|
||||
///
|
||||
/// RFC 9110 §8.4:codings 按**应用顺序**列出,故解压须**反向**(最后应用的先解)。
|
||||
/// 返回 `Ok(None)` 表示存在不受支持的编码、原样透传——此时调用方必须保留
|
||||
/// content-encoding 头,否则下游(诊断 / 客户端)会把压缩字节误当明文。
|
||||
pub(crate) fn decompress_body_with_limit(
|
||||
pub(crate) fn decompress_body(
|
||||
content_encoding: &str,
|
||||
body: &[u8],
|
||||
max_output_bytes: usize,
|
||||
) -> Result<Option<Vec<u8>>, DecompressError> {
|
||||
) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
let codings = split_codings(content_encoding);
|
||||
if codings.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -149,7 +90,7 @@ pub(crate) fn decompress_body_with_limit(
|
||||
let mut data: Option<Vec<u8>> = None;
|
||||
for coding in codings.iter().rev() {
|
||||
let input = data.as_deref().unwrap_or(body);
|
||||
match decompress_single(coding, input, max_output_bytes)? {
|
||||
match decompress_single(coding, input)? {
|
||||
Some(decompressed) => data = Some(decompressed),
|
||||
// 上面 is_single_supported 已校验,理论不会发生;防御性兜底。
|
||||
None => return Ok(None),
|
||||
@@ -158,15 +99,6 @@ pub(crate) fn decompress_body_with_limit(
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// 无输出上限的 [`decompress_body_with_limit`] 版本,供请求侧等已有自身
|
||||
/// 体积约束的调用方使用。
|
||||
pub(crate) fn decompress_body(
|
||||
content_encoding: &str,
|
||||
body: &[u8],
|
||||
) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
decompress_body_with_limit(content_encoding, body, usize::MAX).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// 该 content-encoding(含堆叠,如 `gzip, zstd`)是否全部可被解压。
|
||||
///
|
||||
/// 请求侧用它做闸门:无法解压的压缩体不能透传给 JSON 解析,需直接拒绝。
|
||||
@@ -263,116 +195,6 @@ mod tests {
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
/// 生成确定性伪随机字节(LCG),避免测试引入 rand 依赖。
|
||||
fn pseudo_random_bytes(len: usize) -> Vec<u8> {
|
||||
let mut state: u64 = 0x243F_6A88_85A3_08D3;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
state = state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
(state >> 33) as u8
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn gzip_compress(payload: &[u8]) -> Vec<u8> {
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_with_limit_passes_payload_under_limit() {
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let compressed = gzip_compress(payload);
|
||||
|
||||
let out = decompress_body_with_limit("gzip", &compressed, 1024)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(out, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_with_limit_allows_exactly_limit_bytes() {
|
||||
let payload = vec![7u8; 64 * 1024];
|
||||
let compressed = gzip_compress(&payload);
|
||||
|
||||
let out = decompress_body_with_limit("gzip", &compressed, 64 * 1024)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(out.len(), 64 * 1024);
|
||||
assert_eq!(out, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_with_limit_aborts_gzip_bomb_mid_stream() {
|
||||
// 4 MiB 伪随机数据(压缩率约 1:1)gzip 后截断到 2 MiB:流在产出约 2 MiB
|
||||
// 解压数据后 abrupt 结束。有界读取应在 1 MiB 预算耗尽处报 TooLarge;
|
||||
// 无界读取会一路读到残缺的流尾报 UnexpectedEof(Io)——两者可区分,
|
||||
// 因此该测试能识别"先完整展开再比较"的退化。
|
||||
let payload = pseudo_random_bytes(4 * 1024 * 1024);
|
||||
let compressed = gzip_compress(&payload);
|
||||
assert!(compressed.len() > 2 * 1024 * 1024);
|
||||
let truncated = &compressed[..2 * 1024 * 1024];
|
||||
|
||||
let result = decompress_body_with_limit("gzip", truncated, 1024 * 1024);
|
||||
assert!(
|
||||
matches!(result, Err(DecompressError::TooLarge { .. })),
|
||||
"应在预算耗尽处截停(TooLarge),而不是读到流尾才报错: {:?}",
|
||||
result.as_ref().map(|o| o.as_ref().map(Vec::len))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_with_limit_rejects_zstd_bomb() {
|
||||
// 高压缩比 payload:8 MiB 全零 → zstd 压缩后仅数 KiB,完整展开必然超限
|
||||
let payload = vec![0u8; 8 * 1024 * 1024];
|
||||
let compressed = zstd::stream::encode_all(std::io::Cursor::new(&payload[..]), 0).unwrap();
|
||||
assert!(compressed.len() < 1024 * 1024);
|
||||
|
||||
let result = decompress_body_with_limit("zstd", &compressed, 1024 * 1024);
|
||||
assert!(
|
||||
matches!(result, Err(DecompressError::TooLarge { .. })),
|
||||
"zstd 压缩炸弹应在预算耗尽处截停: {:?}",
|
||||
result.as_ref().map(|o| o.as_ref().map(Vec::len))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_with_limit_rejects_brotli_bomb() {
|
||||
let payload = vec![0u8; 8 * 1024 * 1024];
|
||||
let mut compressed = Vec::new();
|
||||
{
|
||||
let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 5, 22);
|
||||
std::io::Write::write_all(&mut writer, &payload).unwrap();
|
||||
}
|
||||
assert!(compressed.len() < 1024 * 1024);
|
||||
|
||||
let result = decompress_body_with_limit("br", &compressed, 1024 * 1024);
|
||||
assert!(
|
||||
matches!(result, Err(DecompressError::TooLarge { .. })),
|
||||
"brotli 压缩炸弹应在预算耗尽处截停: {:?}",
|
||||
result.as_ref().map(|o| o.as_ref().map(Vec::len))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_with_limit_bounds_intermediate_stage_of_stacked_encodings() {
|
||||
// 堆叠编码 gzip, zstd:zstd 先解出 gzip 流(小),gzip 再展开成 8 MiB。
|
||||
// 中间产物同样受预算约束,不能只在最后一级设防。
|
||||
let payload = vec![0u8; 8 * 1024 * 1024];
|
||||
let gzipped = gzip_compress(&payload);
|
||||
let stacked = zstd::stream::encode_all(std::io::Cursor::new(&gzipped[..]), 0).unwrap();
|
||||
|
||||
let result = decompress_body_with_limit("gzip, zstd", &stacked, 1024 * 1024);
|
||||
assert!(
|
||||
matches!(result, Err(DecompressError::TooLarge { .. })),
|
||||
"堆叠编码的中间解压产物也应受预算约束: {:?}",
|
||||
result.as_ref().map(|o| o.as_ref().map(Vec::len))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_supported_content_encoding_matches_decompressable() {
|
||||
for enc in [
|
||||
|
||||
@@ -8,9 +8,6 @@ use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProxyError {
|
||||
#[error("上游响应体超过大小上限: {0} 字节")]
|
||||
ResponseBodyTooLarge(usize),
|
||||
|
||||
#[error("服务器已在运行")]
|
||||
AlreadyRunning,
|
||||
|
||||
@@ -159,9 +156,6 @@ impl IntoResponse for ProxyError {
|
||||
ProxyError::Internal(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::ResponseBodyTooLarge(_) => {
|
||||
(StatusCode::BAD_GATEWAY, self.to_string())
|
||||
}
|
||||
ProxyError::UpstreamError { .. } => unreachable!(),
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! 负责将请求转发到上游Provider,支持故障转移
|
||||
|
||||
use super::hyper_client::{ProxyResponse, MAX_RESPONSE_BODY_BYTES};
|
||||
use super::hyper_client::ProxyResponse;
|
||||
use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
content_encoding::{decompress_body_with_limit, get_content_encoding},
|
||||
content_encoding::{decompress_body, get_content_encoding},
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
json_canonical::{canonicalize_value, short_value_hash},
|
||||
@@ -1183,15 +1183,6 @@ impl RequestForwarder {
|
||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
|
||||
.await;
|
||||
// Strip the [1M] context marker after Copilot normalization/resolve.
|
||||
// A user's mapped value (e.g. "gpt-5.6-sol[1M]") carries [1M] as a
|
||||
// Claude Code context-capability declaration that upstream APIs reject
|
||||
// as part of the model name. The preceding normalization step already
|
||||
// rewrites claude-xxx[1M] into the "-1m" dash form Copilot accepts, and
|
||||
// the strip helper only touches the "[1m]" bracket form, so "-1m"
|
||||
// variants pass through unchanged.
|
||||
mapped_body =
|
||||
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
|
||||
} else if !codex_responses_to_anthropic {
|
||||
// Skip on the Codex→Anthropic path: stripping [1m] here would break both the
|
||||
// model-catalog match (apply_codex_upstream_model) and the transform's own
|
||||
@@ -2317,16 +2308,13 @@ impl RequestForwarder {
|
||||
// 自动解压 feature,这里拿到的是原始字节;不解压的话,压缩过的错误体会
|
||||
// 在 from_utf8 处变成非 UTF-8 而被丢弃,隐藏掉上游的限流/鉴权等详情。
|
||||
let encoding = get_content_encoding(response.headers());
|
||||
let raw = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await?;
|
||||
let raw = response.bytes().await?;
|
||||
let decoded = match encoding {
|
||||
Some(encoding) => {
|
||||
match decompress_body_with_limit(&encoding, &raw, MAX_RESPONSE_BODY_BYTES) {
|
||||
Some(encoding) => match decompress_body(&encoding, &raw) {
|
||||
Ok(Some(decompressed)) => decompressed,
|
||||
// 不支持的编码 / 解压失败 / 解压后超限:退回(已有上限的)
|
||||
// 原始字节,尽量保留可读信息
|
||||
// 不支持的编码 / 解压失败:退回原始字节,尽量保留可读信息
|
||||
_ => raw.to_vec(),
|
||||
}
|
||||
}
|
||||
},
|
||||
None => raw.to_vec(),
|
||||
};
|
||||
let body_text = String::from_utf8(decoded).ok();
|
||||
@@ -2358,10 +2346,7 @@ impl RequestForwarder {
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body_timeout = self.non_streaming_timeout;
|
||||
let body = tokio::time::timeout(
|
||||
body_timeout,
|
||||
response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES),
|
||||
)
|
||||
let body = tokio::time::timeout(body_timeout, response.bytes())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProxyError::Timeout(format!(
|
||||
@@ -2383,14 +2368,12 @@ impl RequestForwarder {
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let encoding = get_content_encoding(&headers);
|
||||
let raw = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await?;
|
||||
let raw = response.bytes().await?;
|
||||
let decoded = match encoding {
|
||||
Some(encoding) => {
|
||||
match decompress_body_with_limit(&encoding, &raw, MAX_RESPONSE_BODY_BYTES) {
|
||||
Some(encoding) => match decompress_body(&encoding, &raw) {
|
||||
Ok(Some(decompressed)) => decompressed,
|
||||
_ => raw.to_vec(),
|
||||
}
|
||||
}
|
||||
},
|
||||
None => raw.to_vec(),
|
||||
};
|
||||
|
||||
@@ -2410,14 +2393,12 @@ impl RequestForwarder {
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let encoding = get_content_encoding(&headers);
|
||||
let raw = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await?;
|
||||
let raw = response.bytes().await?;
|
||||
let decoded = match encoding {
|
||||
Some(encoding) => {
|
||||
match decompress_body_with_limit(&encoding, &raw, MAX_RESPONSE_BODY_BYTES) {
|
||||
Some(encoding) => match decompress_body(&encoding, &raw) {
|
||||
Ok(Some(decompressed)) => decompressed,
|
||||
_ => raw.to_vec(),
|
||||
}
|
||||
}
|
||||
},
|
||||
None => raw.to_vec(),
|
||||
};
|
||||
|
||||
@@ -3903,10 +3884,7 @@ mod tests {
|
||||
.expect("response should be buffered");
|
||||
|
||||
assert_eq!(
|
||||
prepared
|
||||
.bytes_with_limit(MAX_RESPONSE_BODY_BYTES)
|
||||
.await
|
||||
.unwrap(),
|
||||
prepared.bytes().await.unwrap(),
|
||||
Bytes::from_static(b"{\"ok\":true}")
|
||||
);
|
||||
}
|
||||
@@ -3951,10 +3929,7 @@ mod tests {
|
||||
.expect("stream should be primed");
|
||||
|
||||
assert_eq!(
|
||||
prepared
|
||||
.bytes_with_limit(MAX_RESPONSE_BODY_BYTES)
|
||||
.await
|
||||
.unwrap(),
|
||||
prepared.bytes().await.unwrap(),
|
||||
Bytes::from_static(b"firstsecond")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
/// cc-switch–owned `model_catalog_json`, using the same path ownership rules as
|
||||
/// Codex live-setting import.
|
||||
pub async fn handle_models() -> Result<Json<Value>, ProxyError> {
|
||||
let config_dir = crate::codex_config::get_codex_config_dir();
|
||||
let generated_path = crate::codex_config::get_codex_model_catalog_path();
|
||||
let active_catalog_path = match crate::codex_config::read_codex_config_text() {
|
||||
Ok(config_text) => {
|
||||
crate::codex_config::resolve_cc_switch_catalog_path(&config_text, &config_dir)
|
||||
crate::codex_config::resolve_cc_switch_catalog_path(&config_text, &generated_path)
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
@@ -92,13 +92,8 @@ pub async fn handle_models() -> Result<Json<Value>, ProxyError> {
|
||||
let catalog = if let Some(catalog_path) =
|
||||
active_catalog_path.as_ref().filter(|path| path.exists())
|
||||
{
|
||||
match crate::codex_config::read_codex_model_catalog_text(catalog_path) {
|
||||
Ok(text) => serde_json::from_str(&text).unwrap_or(json!({"models": []})),
|
||||
Err(error) => {
|
||||
log::warn!("[models] 拒绝读取越界或过大的目录文件: {error}");
|
||||
json!({"models": []})
|
||||
}
|
||||
}
|
||||
let text = std::fs::read_to_string(catalog_path).unwrap_or_default();
|
||||
serde_json::from_str(&text).unwrap_or(json!({"models": []}))
|
||||
} else {
|
||||
if active_catalog_path.is_none() {
|
||||
log::debug!(
|
||||
@@ -1901,8 +1896,7 @@ fn codex_proxy_error_code(error: &ProxyError) -> &'static str {
|
||||
| ProxyError::NotRunning
|
||||
| ProxyError::BindFailed(_)
|
||||
| ProxyError::StopTimeout
|
||||
| ProxyError::StopFailed(_)
|
||||
| ProxyError::ResponseBodyTooLarge(_) => "cc_switch_proxy_error",
|
||||
| ProxyError::StopFailed(_) => "cc_switch_proxy_error",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,10 +72,6 @@ fn global_hyper_client() -> &'static HyperClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// 响应体读取上限(128 MiB)。正常非流式补全响应只有几十到几百 KiB;超过则视为
|
||||
/// 上游异常或恶意 payload,直接拒绝,避免代理进程被超大响应体/压缩炸弹耗尽内存。
|
||||
pub(crate) const MAX_RESPONSE_BODY_BYTES: usize = 128 * 1024 * 1024;
|
||||
|
||||
/// Unified response wrapper that can hold either a hyper or reqwest response.
|
||||
///
|
||||
/// The hyper variant is used for the main (direct) path with header-case preservation.
|
||||
@@ -161,31 +157,25 @@ impl ProxyResponse {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Consume the response and collect the full body into `Bytes`, aborting the
|
||||
/// read as soon as the accumulated body exceeds `max_bytes`.
|
||||
///
|
||||
/// 所有变体都在累积过程中逐块检查、超限即断开(drop stream 中止上游连接),
|
||||
/// 而不是先收满再比较——否则超大明文 body 仍会完整进入内存,限制形同虚设。
|
||||
pub async fn bytes_with_limit(self, max_bytes: usize) -> Result<Bytes, ProxyError> {
|
||||
/// Consume the response and collect the full body into `Bytes`.
|
||||
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
|
||||
match self {
|
||||
Self::Buffered { body, .. } => {
|
||||
// 调用方已把 body 完整缓冲,无法中途截停,只能事后比较
|
||||
if body.len() > max_bytes {
|
||||
return Err(ProxyError::ResponseBodyTooLarge(body.len()));
|
||||
Self::Hyper(r) => {
|
||||
let collected = r.into_body().collect().await.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
Ok(collected.to_bytes())
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
response => {
|
||||
// Hyper / Reqwest / Streamed 统一走逐块流式累积,超预算立即报错
|
||||
let mut stream = response.bytes_stream();
|
||||
Self::Reqwest(r) => r.bytes().await.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
}),
|
||||
Self::Buffered { body, .. } => Ok(body),
|
||||
Self::Streamed { mut stream, .. } => {
|
||||
let mut body = bytes::BytesMut::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
if body.len() + chunk.len() > max_bytes {
|
||||
return Err(ProxyError::ResponseBodyTooLarge(body.len() + chunk.len()));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(body.freeze())
|
||||
@@ -790,153 +780,4 @@ mod tests {
|
||||
assert!(!buffered_with_content_type(Some("text/event-stream")).is_json());
|
||||
assert!(!buffered_with_content_type(None).is_json());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bytes_with_limit_rejects_oversized_buffered_response() {
|
||||
let oversized = Bytes::from(vec![0u8; MAX_RESPONSE_BODY_BYTES + 1]);
|
||||
let response =
|
||||
ProxyResponse::buffered(http::StatusCode::OK, http::HeaderMap::new(), oversized);
|
||||
|
||||
let result = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await;
|
||||
assert!(matches!(result, Err(ProxyError::ResponseBodyTooLarge(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bytes_with_limit_rejects_oversized_streamed_response() {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, std::io::Error>>(2);
|
||||
let stream = futures::stream::unfold(rx, |mut rx| async move {
|
||||
rx.recv().await.map(|item| (item, rx))
|
||||
});
|
||||
let response =
|
||||
ProxyResponse::streamed(http::StatusCode::OK, http::HeaderMap::new(), stream);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(Ok(Bytes::from(vec![0u8; 64 * 1024]))).await;
|
||||
let _ = tx
|
||||
.send(Ok(Bytes::from(vec![0u8; MAX_RESPONSE_BODY_BYTES])))
|
||||
.await;
|
||||
});
|
||||
|
||||
let result = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await;
|
||||
assert!(matches!(result, Err(ProxyError::ResponseBodyTooLarge(_))));
|
||||
}
|
||||
|
||||
/// 启动一个最小 HTTP/1.1 服务器:响应 `Content-Length: body_len` 的全零 body,
|
||||
/// 分块写出并统计实际写成功的字节数(客户端断开后写入失败即停)。
|
||||
async fn spawn_fixed_body_server(
|
||||
body_len: usize,
|
||||
) -> (u16, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let written = Arc::new(AtomicUsize::new(0));
|
||||
let written_report = written.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let Ok((mut socket, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
// 读完请求头(内容不重要)
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut filled = 0;
|
||||
loop {
|
||||
if buf[..filled].windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
let Ok(n) = socket.read(&mut buf[filled..]).await else {
|
||||
return;
|
||||
};
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
filled += n;
|
||||
}
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/octet-stream\r\ncontent-length: {body_len}\r\nconnection: close\r\n\r\n"
|
||||
);
|
||||
if socket.write_all(header.as_bytes()).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let chunk = [0u8; 16 * 1024];
|
||||
let mut remaining = body_len;
|
||||
while remaining > 0 {
|
||||
let n = remaining.min(chunk.len());
|
||||
if socket.write_all(&chunk[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
written.fetch_add(n, Ordering::SeqCst);
|
||||
remaining -= n;
|
||||
}
|
||||
});
|
||||
|
||||
(port, written_report)
|
||||
}
|
||||
|
||||
/// 客户端断开到服务器写入失败之间有时延(loopback 缓冲区会再吞一部分),
|
||||
/// 稍等再读计数。只要客户端真的中途截停,服务器绝不可能写出大半个 body。
|
||||
async fn assert_server_aborted_early(
|
||||
written: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
body_len: usize,
|
||||
) {
|
||||
use std::sync::atomic::Ordering;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let written = written.load(Ordering::SeqCst);
|
||||
assert!(
|
||||
written < body_len / 2,
|
||||
"客户端应在预算耗尽后立即断开,服务器不应写出大部分 body(实际已写 {written}/{body_len} 字节)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bytes_with_limit_aborts_hyper_response_before_full_body_arrives() {
|
||||
const BODY_LEN: usize = 16 * 1024 * 1024;
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
let (port, written) = spawn_fixed_body_server(BODY_LEN).await;
|
||||
|
||||
// 构造真实的 hyper::Response<Incoming>:手工建立 http1 客户端连接
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.unwrap();
|
||||
let io = hyper_util::rt::TokioIo::new(stream);
|
||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = conn.await;
|
||||
});
|
||||
let request = http::Request::builder()
|
||||
.uri(format!("http://127.0.0.1:{port}/"))
|
||||
.body(http_body_util::Empty::<Bytes>::new())
|
||||
.unwrap();
|
||||
let response = sender.send_request(request).await.unwrap();
|
||||
assert_eq!(response.status(), http::StatusCode::OK);
|
||||
|
||||
let result = ProxyResponse::Hyper(response).bytes_with_limit(LIMIT).await;
|
||||
assert!(matches!(result, Err(ProxyError::ResponseBodyTooLarge(_))));
|
||||
|
||||
// 关键断言:若退回"先 collect 收满再比较",服务器会把 16 MiB 全部写完
|
||||
assert_server_aborted_early(written, BODY_LEN).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bytes_with_limit_aborts_reqwest_response_before_full_body_arrives() {
|
||||
const BODY_LEN: usize = 16 * 1024 * 1024;
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
let (port, written) = spawn_fixed_body_server(BODY_LEN).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:{port}/"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let result = ProxyResponse::Reqwest(response)
|
||||
.bytes_with_limit(LIMIT)
|
||||
.await;
|
||||
assert!(matches!(result, Err(ProxyError::ResponseBodyTooLarge(_))));
|
||||
|
||||
assert_server_aborted_early(written, BODY_LEN).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +80,6 @@ struct ChatToResponsesState {
|
||||
latest_usage: Option<Value>,
|
||||
finish_reason: Option<String>,
|
||||
tool_context: CodexToolContext,
|
||||
/// 本回合因缺少合法函数名而被丢弃的工具调用数(见 `finalize_tools`)。
|
||||
dropped_tool_calls: usize,
|
||||
}
|
||||
|
||||
impl Default for ChatToResponsesState {
|
||||
@@ -102,7 +100,6 @@ impl Default for ChatToResponsesState {
|
||||
latest_usage: None,
|
||||
finish_reason: None,
|
||||
tool_context: CodexToolContext::default(),
|
||||
dropped_tool_calls: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,43 +332,8 @@ impl ChatToResponsesState {
|
||||
(!self.reasoning.text.trim().is_empty()).then(|| self.reasoning.text.trim().to_string())
|
||||
}
|
||||
|
||||
/// 上游未下发 `index` 时的 key 解析。
|
||||
///
|
||||
/// `index` 在 OpenAI Chat Completions 协议里是必填字段,但部分第三方网关会省略。
|
||||
/// 缺了它就无法从帧结构上区分「同一调用的 arguments 续帧」和「一个新调用」,
|
||||
/// 所以这里只在**能确证是新调用**时才分配新 key:delta 带非空 `id`,且该 id 与
|
||||
/// 所有已知调用都不同。其余情况一律归入最后一个已知 key(空 map 时为 0),保持
|
||||
/// 既有行为——宁可两个并行调用坍缩成一个,也不能把一个调用的续帧炸成多个 item。
|
||||
fn resolve_tool_key_without_index(&self, tool_call: &Value) -> usize {
|
||||
let last_key = self.tools.keys().next_back().copied();
|
||||
|
||||
let Some(id) = tool_call
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|id| !id.is_empty())
|
||||
else {
|
||||
return last_key.unwrap_or(0);
|
||||
};
|
||||
|
||||
if let Some((key, _)) = self.tools.iter().find(|(_, state)| state.call_id == id) {
|
||||
return *key;
|
||||
}
|
||||
|
||||
// 上游可以先发一个显式 `index: usize::MAX` 再发无 index 的新 id。这段代码
|
||||
// 存在的理由就是应付畸形上游,所以不能用裸 `+1`(debug 下 panic、release 下
|
||||
// 回绕到 0 覆盖已有调用)。溢出时退回并入最后一个已知调用,与本函数
|
||||
// "宁可坍缩也不炸开" 的取向一致。
|
||||
match last_key {
|
||||
Some(key) => key.checked_add(1).unwrap_or(key),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_tool_call_delta(&mut self, tool_call: &Value, reasoning: Option<&str>) -> Vec<Bytes> {
|
||||
let chat_index = match tool_call.get("index").and_then(|v| v.as_u64()) {
|
||||
Some(index) => index as usize,
|
||||
None => self.resolve_tool_key_without_index(tool_call),
|
||||
};
|
||||
let chat_index = tool_call.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
let id_delta = tool_call
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -521,16 +483,6 @@ impl ChatToResponsesState {
|
||||
})
|
||||
}
|
||||
|
||||
/// 本回合最终产出里是否至少有一个可被 Codex 识别的工具调用 item。
|
||||
fn has_emitted_tool_call(&self) -> bool {
|
||||
self.output_items.iter().any(|(_, item)| {
|
||||
matches!(
|
||||
item.get("type").and_then(|v| v.as_str()),
|
||||
Some("function_call" | "custom_tool_call" | "tool_search_call")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn finalize(&mut self) -> Vec<Bytes> {
|
||||
if self.completed {
|
||||
return Vec::new();
|
||||
@@ -543,27 +495,6 @@ impl ChatToResponsesState {
|
||||
events.extend(self.finalize_tools());
|
||||
|
||||
let status = response_status_from_finish_reason(self.finish_reason.as_deref());
|
||||
|
||||
// 丢弃过工具调用、且最终一个工具调用都没剩下时,Codex 会收到一个
|
||||
// "status=completed 但 output 里没有任何工具调用" 的回合,agent loop 必然
|
||||
// 静默收尾——这正是 #4341「答一句就停、零报错」的形态。此时如实报错,
|
||||
// 而不是谎报成功。只要还剩下任何一个合法工具调用,Codex 本来就会继续,
|
||||
// 判据不成立,行为保持不变。
|
||||
//
|
||||
// 🔴 只对本应 `completed` 的回合生效:`finish_reason=length`(含流提前断开后
|
||||
// 合成的 length)有自己正当的终止解释,工具调用没拿到 name 是截断的后果而非
|
||||
// 上游发了畸形数据——报成 tool_call_dropped 会给出错误的归因,而本修复的全部
|
||||
// 意义就在于诊断信息的准确性。
|
||||
if status == "completed" && self.dropped_tool_calls > 0 && !self.has_emitted_tool_call() {
|
||||
let dropped = self.dropped_tool_calls;
|
||||
let message = format!(
|
||||
"Upstream returned {dropped} tool call(s) without a function name, \
|
||||
leaving no usable tool call in this turn"
|
||||
);
|
||||
events.push(self.failed_event(message, Some("upstream_tool_call_dropped".to_string())));
|
||||
return events;
|
||||
}
|
||||
|
||||
let mut response = self.base_response(status, self.completed_output_items());
|
||||
if status == "incomplete" {
|
||||
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
|
||||
@@ -614,35 +545,16 @@ impl ChatToResponsesState {
|
||||
|
||||
// Skip tool calls with missing names (defensive: some models generate
|
||||
// tool call deltas without providing a valid function name)
|
||||
// 纯空白名同样对应不到任何已发布工具,必须与空名同等对待——否则它会
|
||||
// 伪装成"本回合还有工具调用",绕过下面 finalize 里的失败判据。
|
||||
let has_bad_name = self
|
||||
.tools
|
||||
.get(&key)
|
||||
.map(|state| state.name.trim().is_empty())
|
||||
.map(|state| state.name.is_empty())
|
||||
.unwrap_or(true);
|
||||
if has_bad_name {
|
||||
let (call_id_empty, args_bytes) = self
|
||||
.tools
|
||||
.get(&key)
|
||||
.map(|state| (state.call_id.is_empty(), state.arguments.len()))
|
||||
.unwrap_or((true, 0));
|
||||
if let Some(state) = self.tools.get_mut(&key) {
|
||||
state.done = true;
|
||||
}
|
||||
self.dropped_tool_calls += 1;
|
||||
// 只记结构信息:arguments 内容可能包含用户代码,且前端日志出口是
|
||||
// allowlist 脱敏,新字段不进白名单就不会被处理,因此只输出字节数。
|
||||
log::warn!(
|
||||
"[Codex] dropped streaming tool call: model={} chat_index={} \
|
||||
call_id_empty={} args_bytes={} finish_reason={} tools_total={}",
|
||||
self.model,
|
||||
key,
|
||||
call_id_empty,
|
||||
args_bytes,
|
||||
self.finish_reason.as_deref().unwrap_or("<none>"),
|
||||
self.tools.len()
|
||||
);
|
||||
log::warn!("[Codex] Skipping streaming tool call with missing name");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1121,138 +1033,6 @@ mod tests {
|
||||
assert!(!output.contains("call_missing"));
|
||||
}
|
||||
|
||||
/// #4341:上游只给出畸形工具调用时,丢弃后本回合一个工具调用都不剩,
|
||||
/// Codex 会把它当成正常完成而静默收尾。此时必须如实报错。
|
||||
#[tokio::test]
|
||||
async fn dropped_only_tool_call_emits_failed_without_completed() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_drop\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"content\":\"让我继续处理这个文件\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_drop\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_bad\",\"type\":\"function\",\"function\":{\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.failed"));
|
||||
assert!(output.contains("upstream_tool_call_dropped"));
|
||||
assert!(!output.contains("event: response.completed"));
|
||||
// 已经推给客户端的文本增量不受影响,用户仍能看到模型说了什么。
|
||||
assert!(output.contains("让我继续处理这个文件"));
|
||||
}
|
||||
|
||||
/// `finish_reason=length`(token 截断)时工具调用往往只到一半就没了 name。
|
||||
/// 这不是"上游发了畸形数据",而是截断——归因必须是 incomplete,不能报成
|
||||
/// tool_call_dropped,否则诊断信息本身就是错的。
|
||||
#[tokio::test]
|
||||
async fn truncated_turn_stays_incomplete_instead_of_failed() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_trunc\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"content\":\"我来看看\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_trunc\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_cut\",\"type\":\"function\",\"function\":{\"arguments\":\"{\\\"pa\"}}]},\"finish_reason\":\"length\"}]}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.completed"));
|
||||
assert!(output.contains("\"status\":\"incomplete\""));
|
||||
assert!(!output.contains("event: response.failed"));
|
||||
}
|
||||
|
||||
/// 纯空白函数名对应不到任何已发布工具,必须与空名同等对待,
|
||||
/// 否则它会伪装成"本回合还有工具调用"而绕过判据。
|
||||
#[tokio::test]
|
||||
async fn whitespace_only_tool_name_is_dropped() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_ws\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_ws\",\"type\":\"function\",\"function\":{\"name\":\" \",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.failed"));
|
||||
assert!(output.contains("upstream_tool_call_dropped"));
|
||||
assert!(!output.contains("event: response.completed"));
|
||||
}
|
||||
|
||||
/// 纯文本回合(从未出现过工具调用增量)不受判据影响。
|
||||
#[tokio::test]
|
||||
async fn text_only_turn_still_completes() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_text\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"content\":\"完成了\"},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.completed"));
|
||||
assert!(!output.contains("event: response.failed"));
|
||||
}
|
||||
|
||||
/// 上游省略 `index` 时,两个 id 不同的调用不得坍缩成一个。
|
||||
#[tokio::test]
|
||||
async fn missing_index_with_distinct_ids_keeps_calls_separate() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_noidx\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"a.txt\\\"}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_noidx\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_b\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\\\"cmd\\\":\\\"ls\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
let events = parse_sse_events(&output);
|
||||
let completed = events
|
||||
.iter()
|
||||
.find(|event| event["type"] == "response.completed")
|
||||
.unwrap();
|
||||
let items = completed["response"]["output"].as_array().unwrap();
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0]["call_id"], "call_a");
|
||||
assert_eq!(items[0]["name"], "read_file");
|
||||
assert_eq!(items[0]["arguments"], r#"{"path":"a.txt"}"#);
|
||||
assert_eq!(items[1]["call_id"], "call_b");
|
||||
assert_eq!(items[1]["name"], "exec_command");
|
||||
assert_eq!(items[1]["arguments"], r#"{"cmd":"ls"}"#);
|
||||
}
|
||||
|
||||
/// 上游省略 `index` 时,不带 id 的 arguments 续帧必须归入同一个调用,
|
||||
/// 不能被当成新调用炸成多个 item。
|
||||
#[tokio::test]
|
||||
async fn missing_index_argument_fragments_stay_in_one_call() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_frag\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_frag\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"function\":{\"arguments\":\"\\\"a.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
let events = parse_sse_events(&output);
|
||||
let completed = events
|
||||
.iter()
|
||||
.find(|event| event["type"] == "response.completed")
|
||||
.unwrap();
|
||||
let items = completed["response"]["output"].as_array().unwrap();
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["call_id"], "call_a");
|
||||
assert_eq!(items[0]["arguments"], r#"{"path":"a.txt"}"#);
|
||||
}
|
||||
|
||||
/// 上游省略 `index` 且重复下发同一个 id(部分网关每帧重复整个头部)时,
|
||||
/// 不得被判成新调用。
|
||||
#[tokio::test]
|
||||
async fn missing_index_repeated_same_id_stays_in_one_call() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_rep\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_rep\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"\\\"a.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
let events = parse_sse_events(&output);
|
||||
let completed = events
|
||||
.iter()
|
||||
.find(|event| event["type"] == "response.completed")
|
||||
.unwrap();
|
||||
let items = completed["response"]["output"].as_array().unwrap();
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["call_id"], "call_a");
|
||||
assert_eq!(items[0]["arguments"], r#"{"path":"a.txt"}"#);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finalization_keeps_non_contiguous_tool_index() {
|
||||
let output = collect(vec![
|
||||
|
||||
@@ -1409,28 +1409,11 @@ pub(crate) fn chat_completion_to_response_with_context(
|
||||
if let Some(message_item) = chat_message_to_response_output_item(message, &response_id) {
|
||||
output.push(message_item);
|
||||
}
|
||||
let tool_calls =
|
||||
chat_tool_calls_to_response_output_items(message, reasoning.as_deref(), tool_context);
|
||||
|
||||
// 丢弃过工具调用、且最终一个工具调用都没剩下时,Codex 会收到一个
|
||||
// "status=completed 但 output 里没有任何工具调用" 的回合,agent loop 必然静默
|
||||
// 收尾(#4341)。此时如实报错,而不是谎报成功。只要还剩下任何一个合法工具
|
||||
// 调用,Codex 本来就会继续,判据不成立,行为保持不变。
|
||||
//
|
||||
// 🔴 与流式分支一致,只对本应 `completed` 的回合生效:`finish_reason=length`
|
||||
// 是截断,工具调用缺 name 是截断的后果而非上游发了畸形数据,报成
|
||||
// tool_call_dropped 会给出错误的归因。
|
||||
if response_status_from_finish_reason(finish_reason) == "completed"
|
||||
&& tool_calls.dropped > 0
|
||||
&& tool_calls.items.is_empty()
|
||||
{
|
||||
return Err(ProxyError::TransformError(format!(
|
||||
"Upstream returned {} tool call(s) without a function name, \
|
||||
leaving no usable tool call in this turn",
|
||||
tool_calls.dropped
|
||||
)));
|
||||
}
|
||||
output.extend(tool_calls.items);
|
||||
output.extend(chat_tool_calls_to_response_output_items(
|
||||
message,
|
||||
reasoning.as_deref(),
|
||||
tool_context,
|
||||
));
|
||||
|
||||
let mut response = json!({
|
||||
"id": response_id,
|
||||
@@ -1550,20 +1533,12 @@ fn chat_message_to_response_output_item(message: &Value, response_id: &str) -> O
|
||||
}))
|
||||
}
|
||||
|
||||
/// 非流式工具调用转换结果。`dropped` 记录因缺少合法函数名而被丢弃的条数,
|
||||
/// 供调用方判断本回合是否已经不可能让 Codex 继续(见 #4341)。
|
||||
struct ChatToolCallItems {
|
||||
items: Vec<Value>,
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
fn chat_tool_calls_to_response_output_items(
|
||||
message: &Value,
|
||||
reasoning: Option<&str>,
|
||||
tool_context: &CodexToolContext,
|
||||
) -> ChatToolCallItems {
|
||||
) -> Vec<Value> {
|
||||
let mut output = Vec::new();
|
||||
let mut dropped = 0usize;
|
||||
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for (index, tool_call) in tool_calls.iter().enumerate() {
|
||||
@@ -1571,24 +1546,8 @@ fn chat_tool_calls_to_response_output_items(
|
||||
// may generate tool calls without providing a valid name)
|
||||
let function = tool_call.get("function").unwrap_or(&Value::Null);
|
||||
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// 纯空白名同样对应不到任何已发布工具,与空名同等对待。
|
||||
if name.trim().is_empty() {
|
||||
dropped += 1;
|
||||
// 只记结构信息,不记 arguments 内容(可能包含用户代码)。
|
||||
let call_id_empty = tool_call
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_none_or(str::is_empty);
|
||||
let args_bytes = function
|
||||
.get("arguments")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::len)
|
||||
.unwrap_or(0);
|
||||
log::warn!(
|
||||
"[Codex] dropped tool call: index={index} call_id_empty={call_id_empty} \
|
||||
args_bytes={args_bytes} tools_total={}",
|
||||
tool_calls.len()
|
||||
);
|
||||
if name.is_empty() {
|
||||
log::warn!("[Codex] Skipping tool call with missing name");
|
||||
continue;
|
||||
}
|
||||
output.push(chat_tool_call_to_response_item(
|
||||
@@ -1599,16 +1558,14 @@ fn chat_tool_calls_to_response_output_items(
|
||||
));
|
||||
}
|
||||
} else if let Some(function_call) = message.get("function_call") {
|
||||
match chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context) {
|
||||
Some(item) => output.push(item),
|
||||
None => dropped += 1,
|
||||
if let Some(item) =
|
||||
chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context)
|
||||
{
|
||||
output.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
ChatToolCallItems {
|
||||
items: output,
|
||||
dropped,
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn chat_tool_call_to_response_item(
|
||||
@@ -1655,18 +1612,9 @@ fn chat_legacy_function_call_to_response_item(
|
||||
.unwrap_or("");
|
||||
|
||||
// Skip legacy function calls with missing names (defensive: some models
|
||||
// may generate function_call without providing a valid name)。
|
||||
// 纯空白名同样对应不到任何已发布工具,与空名同等对待。
|
||||
if name.trim().is_empty() {
|
||||
// 只记结构信息,不记 arguments 内容(可能包含用户代码)。
|
||||
let args_bytes = function_call
|
||||
.get("arguments")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::len)
|
||||
.unwrap_or(0);
|
||||
log::warn!(
|
||||
"[Codex] dropped legacy function_call: call_id={call_id} args_bytes={args_bytes}"
|
||||
);
|
||||
// may generate function_call without providing a valid name)
|
||||
if name.is_empty() {
|
||||
log::warn!("[Codex] Skipping legacy function_call with missing name");
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -4004,165 +3952,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #4341(非流式路径):丢弃后一个工具调用都不剩时,必须如实报错,
|
||||
/// 而不是返回一个 Codex 会当成正常完成的空壳回合。
|
||||
#[test]
|
||||
fn chat_response_with_only_unnamed_tool_call_is_an_error() {
|
||||
let chat = json!({
|
||||
"id": "chatcmpl_drop",
|
||||
"object": "chat.completion",
|
||||
"created": 123,
|
||||
"model": "kimi-k3",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "让我继续处理这个文件",
|
||||
"tool_calls": [{
|
||||
"id": "call_bad",
|
||||
"type": "function",
|
||||
"function": {"arguments": "{}"}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
|
||||
let err = chat_completion_to_response_with_context(chat, &CodexToolContext::default())
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ProxyError::TransformError(_)));
|
||||
assert!(err.to_string().contains("without a function name"));
|
||||
}
|
||||
|
||||
/// 只要还剩下一个合法工具调用,Codex 本来就会继续,行为保持不变。
|
||||
#[test]
|
||||
fn chat_response_keeps_valid_tool_call_beside_unnamed_one() {
|
||||
let chat = json!({
|
||||
"id": "chatcmpl_mixed",
|
||||
"object": "chat.completion",
|
||||
"created": 123,
|
||||
"model": "kimi-k3",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"id": "call_bad", "type": "function", "function": {"arguments": "{}"}},
|
||||
{
|
||||
"id": "call_good",
|
||||
"type": "function",
|
||||
"function": {"name": "exec_command", "arguments": "{\"cmd\":\"ls\"}"}
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
|
||||
let result =
|
||||
chat_completion_to_response_with_context(chat, &CodexToolContext::default()).unwrap();
|
||||
let output = result["output"].as_array().unwrap();
|
||||
|
||||
assert_eq!(output.len(), 1);
|
||||
assert_eq!(output[0]["name"], "exec_command");
|
||||
assert_eq!(output[0]["call_id"], "call_good");
|
||||
assert_eq!(result["status"], "completed");
|
||||
}
|
||||
|
||||
/// legacy `function_call` 形态同样受判据保护。
|
||||
#[test]
|
||||
fn chat_response_with_unnamed_legacy_function_call_is_an_error() {
|
||||
let chat = json!({
|
||||
"id": "chatcmpl_legacy",
|
||||
"object": "chat.completion",
|
||||
"created": 123,
|
||||
"model": "kimi-k3",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"function_call": {"id": "call_legacy", "arguments": "{}"}
|
||||
},
|
||||
"finish_reason": "function_call"
|
||||
}]
|
||||
});
|
||||
|
||||
let err = chat_completion_to_response_with_context(chat, &CodexToolContext::default())
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ProxyError::TransformError(_)));
|
||||
}
|
||||
|
||||
/// `finish_reason=length` 是截断,不是"上游发了畸形数据"——归因必须保持
|
||||
/// incomplete,不能报成 tool_call_dropped。
|
||||
#[test]
|
||||
fn chat_response_truncated_stays_incomplete_instead_of_error() {
|
||||
let chat = json!({
|
||||
"id": "chatcmpl_trunc",
|
||||
"object": "chat.completion",
|
||||
"created": 123,
|
||||
"model": "kimi-k3",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "我来看看",
|
||||
"tool_calls": [{
|
||||
"id": "call_cut",
|
||||
"type": "function",
|
||||
"function": {"arguments": "{\"pa"}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "length"
|
||||
}]
|
||||
});
|
||||
|
||||
let result =
|
||||
chat_completion_to_response_with_context(chat, &CodexToolContext::default()).unwrap();
|
||||
assert_eq!(result["status"], "incomplete");
|
||||
assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens");
|
||||
}
|
||||
|
||||
/// 纯空白函数名必须与空名同等对待,否则会伪装成"本回合还有工具调用"。
|
||||
#[test]
|
||||
fn chat_response_whitespace_only_tool_name_is_an_error() {
|
||||
let chat = json!({
|
||||
"id": "chatcmpl_ws",
|
||||
"object": "chat.completion",
|
||||
"created": 123,
|
||||
"model": "kimi-k3",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_ws",
|
||||
"type": "function",
|
||||
"function": {"name": " ", "arguments": "{}"}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
|
||||
let err = chat_completion_to_response_with_context(chat, &CodexToolContext::default())
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ProxyError::TransformError(_)));
|
||||
}
|
||||
|
||||
/// 纯文本回合(从未出现工具调用)不受判据影响。
|
||||
#[test]
|
||||
fn chat_response_text_only_still_completes() {
|
||||
let chat = json!({
|
||||
"id": "chatcmpl_text",
|
||||
"object": "chat.completion",
|
||||
"created": 123,
|
||||
"model": "kimi-k3",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "完成了"},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
});
|
||||
|
||||
let result =
|
||||
chat_completion_to_response_with_context(chat, &CodexToolContext::default()).unwrap();
|
||||
assert_eq!(result["status"], "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_to_responses_canonicalizes_json_string_tool_arguments() {
|
||||
let input = json!({
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
//! 统一处理流式和非流式 API 响应
|
||||
|
||||
use super::{
|
||||
content_encoding::{decompress_body_with_limit, get_content_encoding, DecompressError},
|
||||
content_encoding::{decompress_body, get_content_encoding},
|
||||
forwarder::ActiveConnectionGuard,
|
||||
handler_config::{StreamUsageEventFilter, UsageParserConfig},
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
hyper_client::{ProxyResponse, MAX_RESPONSE_BODY_BYTES},
|
||||
hyper_client::ProxyResponse,
|
||||
server::ProxyState,
|
||||
sse::{strip_sse_field, take_sse_block},
|
||||
usage::parser::TokenUsage,
|
||||
@@ -86,11 +86,10 @@ pub(crate) async fn read_decoded_body(
|
||||
) -> Result<(HeaderMap, http::StatusCode, Bytes), ProxyError> {
|
||||
let mut headers = response.headers().clone();
|
||||
let status = response.status();
|
||||
let bytes_future = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES);
|
||||
let raw_bytes = if body_timeout.is_zero() {
|
||||
bytes_future.await?
|
||||
response.bytes().await?
|
||||
} else {
|
||||
tokio::time::timeout(body_timeout, bytes_future)
|
||||
tokio::time::timeout(body_timeout, response.bytes())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProxyError::Timeout(format!(
|
||||
@@ -112,19 +111,15 @@ pub(crate) async fn read_decoded_body(
|
||||
|
||||
if let Some(encoding) = get_content_encoding(&headers) {
|
||||
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
|
||||
match decompress_body_with_limit(&encoding, &raw_bytes, MAX_RESPONSE_BODY_BYTES) {
|
||||
match decompress_body(&encoding, &raw_bytes) {
|
||||
Ok(Some(decompressed)) => {
|
||||
// 解码器在预算耗尽处即截停,此处必然 ≤ MAX_RESPONSE_BODY_BYTES
|
||||
body_bytes = Bytes::from(decompressed);
|
||||
decoded = true;
|
||||
}
|
||||
// 不支持的编码:原样透传且保留 content-encoding 头,
|
||||
// 让下游诊断/客户端知道这仍是压缩字节
|
||||
Ok(None) => {}
|
||||
Err(DecompressError::TooLarge { .. }) => {
|
||||
return Err(ProxyError::ResponseBodyTooLarge(MAX_RESPONSE_BODY_BYTES));
|
||||
}
|
||||
Err(DecompressError::Io(e)) => {
|
||||
Err(e) => {
|
||||
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
|
||||
}
|
||||
}
|
||||
@@ -889,30 +884,6 @@ mod tests {
|
||||
assert!(!formatted.contains("cookie-secret"), "{formatted}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_decoded_body_rejects_compressed_bomb_without_full_expansion() {
|
||||
// 128 MiB+1 全零 payload 的 gzip 只有 ~130 KiB:原始读取上限拦不住它,
|
||||
// 只有解压侧的有界解码能拒绝。若解码退化为"先完整展开再比较",
|
||||
// 展开后长度 > MAX_RESPONSE_BODY_BYTES 的 payload 会成功返回(测试失败)。
|
||||
let payload = vec![0u8; MAX_RESPONSE_BODY_BYTES + 1];
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, &payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
assert!(compressed.len() < MAX_RESPONSE_BODY_BYTES);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("content-encoding", "gzip".parse().unwrap());
|
||||
let response =
|
||||
ProxyResponse::buffered(http::StatusCode::OK, headers, Bytes::from(compressed));
|
||||
|
||||
let result = read_decoded_body(response, "test", Duration::ZERO).await;
|
||||
assert!(
|
||||
matches!(result, Err(ProxyError::ResponseBodyTooLarge(_))),
|
||||
"压缩炸弹应被拒绝而不是完整展开: {:?}",
|
||||
result.map(|(_, _, body)| body.len())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -75,13 +75,15 @@ impl McpService {
|
||||
app: AppType,
|
||||
enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(server) = state
|
||||
.db
|
||||
.update_mcp_server_app_enabled(server_id, &app, enabled)?
|
||||
{
|
||||
let mut servers = state.db.get_all_mcp_servers()?;
|
||||
|
||||
if let Some(server) = servers.get_mut(server_id) {
|
||||
server.apps.set_enabled_for(&app, enabled);
|
||||
state.db.save_mcp_server(server)?;
|
||||
|
||||
// 同步到对应应用
|
||||
if enabled {
|
||||
Self::sync_server_to_app(state, &server, &app)?;
|
||||
Self::sync_server_to_app(state, server, &app)?;
|
||||
} else {
|
||||
Self::remove_server_from_app(state, server_id, &app)?;
|
||||
}
|
||||
|
||||
@@ -95,16 +95,9 @@ impl ProxyService {
|
||||
let auth_policy = if provider.uses_managed_account_auth() {
|
||||
// Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留
|
||||
// ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。
|
||||
// Copilot 默认同样注入 AUTH_TOKEN 占位符:Claude Code(实测 2.1.220)
|
||||
// 对 ANTHROPIC_API_KEY 会弹"是否使用该自定义 key"确认框且默认
|
||||
// "No (recommended)",按默认走后占位符被忽略、落入 Not logged in
|
||||
// (并非 sk-ant-* 格式校验——headless 下占位符原样出站);AUTH_TOKEN
|
||||
// 作为网关 Bearer 被直接信任,零弹窗。仅当供应商表单显式选择了
|
||||
// ANTHROPIC_API_KEY(meta.apiKeyField)时才保留 API_KEY 占位,以规避
|
||||
// 与 /login 管理的 key 冲突(#1049)。
|
||||
// Copilot 维持仅 API_KEY 占位,避免与 /login 管理的 key 冲突(#1049)。
|
||||
ClaudeTakeoverAuthPolicy::ManagedAccount {
|
||||
keep_auth_token: !provider.is_github_copilot()
|
||||
|| !provider.claude_uses_api_key_field(),
|
||||
keep_auth_token: !provider.is_github_copilot(),
|
||||
}
|
||||
} else {
|
||||
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken
|
||||
@@ -204,10 +197,7 @@ impl ProxyService {
|
||||
// - Codex 系保留 AUTH_TOKEN:缺该键 Claude Code 会弹登录提示(#3784)。
|
||||
// 无条件注入而非"已存在才保留":热切换路径传入的是 provider
|
||||
// settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。
|
||||
// - Copilot 默认 AUTH_TOKEN:API_KEY 占位符会触发 Claude Code 的
|
||||
// 自定义 key 确认框(默认 "No (recommended)"),按默认走即
|
||||
// Not logged in;仅当表单显式选择了 ANTHROPIC_API_KEY 时才用
|
||||
// API_KEY 占位以规避 /login key 冲突(#1049)。
|
||||
// - Copilot 仅 API_KEY:避免与 /login 管理的 key 冲突(#1049)。
|
||||
if keep_auth_token {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
@@ -3314,7 +3304,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_account_claude_takeover_uses_auth_token_placeholder() {
|
||||
fn managed_account_claude_takeover_uses_api_key_placeholder() {
|
||||
let mut provider = Provider::with_id(
|
||||
"copilot".to_string(),
|
||||
"GitHub Copilot".to_string(),
|
||||
@@ -3343,13 +3333,13 @@ mod tests {
|
||||
.and_then(|value| value.as_object())
|
||||
.expect("env should exist");
|
||||
assert_eq!(
|
||||
env.get("ANTHROPIC_AUTH_TOKEN")
|
||||
env.get("ANTHROPIC_API_KEY")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some(PROXY_TOKEN_PLACEHOLDER)
|
||||
);
|
||||
assert!(
|
||||
env.get("ANTHROPIC_API_KEY").is_none(),
|
||||
"API_KEY placeholders trigger Claude Code's custom-key approval prompt (defaults to No), landing users in Not logged in"
|
||||
env.get("ANTHROPIC_AUTH_TOKEN").is_none(),
|
||||
"managed OAuth providers should avoid Claude Auth Token login semantics"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3431,8 +3421,8 @@ mod tests {
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
Some("claude-sonnet-4.6[1M]"),
|
||||
);
|
||||
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
|
||||
assert_env_str(env, "ANTHROPIC_API_KEY", None);
|
||||
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
|
||||
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3685,7 +3675,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_account_claude_takeover_copilot_defaults_to_auth_token() {
|
||||
fn managed_account_claude_takeover_copilot_removes_stale_auth_token() {
|
||||
let mut provider = Provider::with_id(
|
||||
"copilot".to_string(),
|
||||
"GitHub Copilot".to_string(),
|
||||
@@ -3701,48 +3691,6 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut live_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://stale.example.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "stale-token",
|
||||
"ANTHROPIC_API_KEY": "stale-key"
|
||||
}
|
||||
});
|
||||
ProxyService::apply_claude_takeover_fields_for_provider(
|
||||
&mut live_config,
|
||||
"http://127.0.0.1:15721",
|
||||
&provider,
|
||||
);
|
||||
|
||||
let env = live_config
|
||||
.get("env")
|
||||
.and_then(|value| value.as_object())
|
||||
.expect("env should exist");
|
||||
// Default Copilot takeover injects AUTH_TOKEN: the API_KEY placeholder
|
||||
// triggers Claude Code's custom-key approval prompt (defaults to
|
||||
// "No (recommended)"), which lands users in "Not logged in".
|
||||
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
|
||||
assert_env_str(env, "ANTHROPIC_API_KEY", None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_account_claude_takeover_copilot_honors_api_key_field_choice() {
|
||||
let mut provider = Provider::with_id(
|
||||
"copilot".to_string(),
|
||||
"GitHub Copilot".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut live_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://stale.example.com",
|
||||
@@ -3759,8 +3707,6 @@ mod tests {
|
||||
.get("env")
|
||||
.and_then(|value| value.as_object())
|
||||
.expect("env should exist");
|
||||
// Explicit API-key-field choice keeps the API_KEY placeholder to avoid
|
||||
// conflicting with the /login-managed key (#1049).
|
||||
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
|
||||
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
|
||||
}
|
||||
|
||||
@@ -450,15 +450,6 @@ fn parse_token_signature(info: &serde_json::Value) -> Option<TokenUsageSignature
|
||||
(total.is_some() || last.is_some()).then_some(TokenUsageSignature { total, last })
|
||||
}
|
||||
|
||||
fn token_snapshot_source(payload: &serde_json::Value) -> Option<String> {
|
||||
payload
|
||||
.get("rate_limits")
|
||||
.and_then(|rate_limits| rate_limits.get("limit_id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn get_codex_sync_state(db: &Database, file_path: &Path) -> Result<(i64, i64), AppError> {
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
let state = get_sync_state(db, &file_path_str)?;
|
||||
@@ -565,26 +556,9 @@ fn compute_delta(prev: &Option<CumulativeTokens>, current: &CumulativeTokens) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn update_high_water(high_water: &mut CumulativeTokens, current: &CumulativeTokens) {
|
||||
high_water.input = high_water.input.max(current.input);
|
||||
high_water.cached_input = high_water.cached_input.max(current.cached_input);
|
||||
high_water.output = high_water.output.max(current.output);
|
||||
}
|
||||
|
||||
/// 从 JSON Value 中提取累计 token 用量
|
||||
fn parse_cumulative_tokens(total_usage: &serde_json::Value) -> Option<CumulativeTokens> {
|
||||
let fields = total_usage.as_object()?;
|
||||
if ![
|
||||
"input_tokens",
|
||||
"cached_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"output_tokens",
|
||||
"reasoning_output_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
.iter()
|
||||
.any(|field| fields.contains_key(*field))
|
||||
{
|
||||
if total_usage.is_null() || !total_usage.is_object() {
|
||||
return None;
|
||||
}
|
||||
Some(CumulativeTokens {
|
||||
@@ -730,17 +704,7 @@ fn parse_codex_file(
|
||||
let mut root_timestamp = None;
|
||||
let mut parent = ParentResolution::None;
|
||||
let mut current_model = "unknown".to_string();
|
||||
// `total_token_usage` is session-cumulative, including across model and
|
||||
// rate-limit bucket changes. Divergent snapshots are handled by preferring
|
||||
// exact `last_token_usage`, not by splitting the cumulative baseline.
|
||||
let mut total_high_water = None;
|
||||
// Rate-limit refreshes can re-emit unchanged token info under another
|
||||
// `limit_id`. Same-source repeats are identified by that source's latest
|
||||
// full snapshot; cross-source repeats must match the immediately preceding
|
||||
// token event. Do not compare against other sources' older snapshots:
|
||||
// those stale signatures can legitimately recur after a counter reset.
|
||||
let mut last_signature_by_source: HashMap<Option<String>, TokenUsageSignature> = HashMap::new();
|
||||
let mut previous_token_signature = None;
|
||||
let mut prev_total: Option<CumulativeTokens> = None;
|
||||
let mut event_index = 0u32;
|
||||
let mut token_events = Vec::new();
|
||||
let mut line_offset = 0i64;
|
||||
@@ -846,52 +810,27 @@ fn parse_codex_file(
|
||||
current_model = normalize_codex_model(model);
|
||||
}
|
||||
|
||||
let snapshot_source = token_snapshot_source(payload);
|
||||
let total = info
|
||||
.get("total_token_usage")
|
||||
.and_then(parse_cumulative_tokens);
|
||||
let last = info
|
||||
.get("last_token_usage")
|
||||
.and_then(parse_cumulative_tokens);
|
||||
if total.is_none() && last.is_none() {
|
||||
continue;
|
||||
}
|
||||
let has_total_snapshot = total.is_some();
|
||||
let duplicate_snapshot = has_total_snapshot
|
||||
&& (last_signature_by_source.get(&snapshot_source) == Some(&signature)
|
||||
|| previous_token_signature.as_ref() == Some(&signature));
|
||||
if has_total_snapshot {
|
||||
last_signature_by_source.insert(snapshot_source, signature.clone());
|
||||
}
|
||||
previous_token_signature = Some(signature.clone());
|
||||
|
||||
let delta = if duplicate_snapshot {
|
||||
DeltaTokens {
|
||||
input: 0,
|
||||
cached_input: 0,
|
||||
output: 0,
|
||||
}
|
||||
} else if let Some(last) = last {
|
||||
// Codex provides the exact per-request usage. Prefer it to
|
||||
// subtracting cumulative snapshots, which may come from
|
||||
// multiple independently advancing rate-limit lanes.
|
||||
DeltaTokens {
|
||||
input: last.input as u32,
|
||||
cached_input: last.cached_input as u32,
|
||||
output: last.output as u32,
|
||||
}
|
||||
} else if let Some(total) = total.as_ref() {
|
||||
compute_delta(&total_high_water, total)
|
||||
let (cumulative, is_total) = if let Some(total) = info.get("total_token_usage") {
|
||||
(parse_cumulative_tokens(total), true)
|
||||
} else if let Some(last) = info.get("last_token_usage") {
|
||||
(parse_cumulative_tokens(last), false)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if let Some(total) = total {
|
||||
if let Some(high_water) = total_high_water.as_mut() {
|
||||
update_high_water(high_water, &total);
|
||||
let Some(cumulative) = cumulative else {
|
||||
continue;
|
||||
};
|
||||
let delta = if is_total {
|
||||
let delta = compute_delta(&prev_total, &cumulative);
|
||||
prev_total = Some(cumulative);
|
||||
delta
|
||||
} else {
|
||||
total_high_water = Some(total);
|
||||
}
|
||||
DeltaTokens {
|
||||
input: cumulative.input as u32,
|
||||
cached_input: cumulative.cached_input as u32,
|
||||
output: cumulative.output as u32,
|
||||
}
|
||||
};
|
||||
let delta = DeltaTokens {
|
||||
cached_input: delta.cached_input.min(delta.input),
|
||||
..delta
|
||||
@@ -1451,18 +1390,14 @@ mod tests {
|
||||
session_meta_at(thread_id, None, None, "2026-07-10T03:00:00Z")
|
||||
}
|
||||
|
||||
fn turn_context_for_model_at(model: &str, timestamp: &str) -> serde_json::Value {
|
||||
fn turn_context_at(timestamp: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"timestamp": timestamp,
|
||||
"type": "turn_context",
|
||||
"payload": { "model": model }
|
||||
"payload": { "model": "gpt-5.6-sol" }
|
||||
})
|
||||
}
|
||||
|
||||
fn turn_context_at(timestamp: &str) -> serde_json::Value {
|
||||
turn_context_for_model_at("gpt-5.6-sol", timestamp)
|
||||
}
|
||||
|
||||
fn turn_context() -> serde_json::Value {
|
||||
turn_context_at("2026-07-10T03:00:01Z")
|
||||
}
|
||||
@@ -1497,43 +1432,6 @@ mod tests {
|
||||
value
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn token_count_with_last_at(
|
||||
total_input: u64,
|
||||
total_cached: u64,
|
||||
total_output: u64,
|
||||
last_input: u64,
|
||||
last_cached: u64,
|
||||
last_output: u64,
|
||||
limit_id: &str,
|
||||
timestamp: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"timestamp": timestamp,
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "token_count",
|
||||
"info": {
|
||||
"total_token_usage": {
|
||||
"input_tokens": total_input,
|
||||
"cached_input_tokens": total_cached,
|
||||
"output_tokens": total_output,
|
||||
"reasoning_output_tokens": 0,
|
||||
"total_tokens": total_input + total_output
|
||||
},
|
||||
"last_token_usage": {
|
||||
"input_tokens": last_input,
|
||||
"cached_input_tokens": last_cached,
|
||||
"output_tokens": last_output,
|
||||
"reasoning_output_tokens": 0,
|
||||
"total_tokens": last_input + last_output
|
||||
}
|
||||
},
|
||||
"rate_limits": { "limit_id": limit_id }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sync_test_file(
|
||||
db: &Database,
|
||||
file: &Path,
|
||||
@@ -1616,430 +1514,6 @@ mod tests {
|
||||
assert!(delta.is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interleaved_counter_lanes_use_exact_last_usage() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
let bengal_event = token_count_with_last_at(
|
||||
87_709_262,
|
||||
83_563_008,
|
||||
240_919,
|
||||
151_258,
|
||||
147_200,
|
||||
87,
|
||||
"codex_bengalfox",
|
||||
"2026-07-10T03:00:03Z",
|
||||
);
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context(),
|
||||
token_count_with_last_at(
|
||||
76_780_408,
|
||||
73_010_432,
|
||||
243_036,
|
||||
175_074,
|
||||
169_728,
|
||||
6_827,
|
||||
"codex",
|
||||
"2026-07-10T03:00:02Z",
|
||||
),
|
||||
bengal_event.clone(),
|
||||
token_count_with_last_at(
|
||||
76_962_538,
|
||||
73_180_160,
|
||||
243_258,
|
||||
182_130,
|
||||
169_728,
|
||||
222,
|
||||
"codex",
|
||||
"2026-07-10T03:00:04Z",
|
||||
),
|
||||
// Repeated snapshots are notifications, not additional API usage.
|
||||
bengal_event,
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| {
|
||||
(
|
||||
event.delta.input,
|
||||
event.delta.cached_input,
|
||||
event.delta.output,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
deltas,
|
||||
vec![
|
||||
(175_074, 169_728, 6_827),
|
||||
(151_258, 147_200, 87),
|
||||
(182_130, 169_728, 222),
|
||||
]
|
||||
);
|
||||
assert!(parsed.token_events[3].delta.is_zero());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_limit_snapshot_replay_is_not_double_counted() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context(),
|
||||
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:02Z"),
|
||||
token_count_with_last_at(
|
||||
1_000,
|
||||
0,
|
||||
10,
|
||||
100,
|
||||
0,
|
||||
10,
|
||||
"codex_bengalfox",
|
||||
"2026-07-10T03:00:03Z",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| event.delta.input)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(deltas, vec![100]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adjacent_replay_burst_across_multiple_sources_is_deduped() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context(),
|
||||
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:02Z"),
|
||||
token_count_with_last_at(
|
||||
1_000,
|
||||
0,
|
||||
10,
|
||||
100,
|
||||
0,
|
||||
10,
|
||||
"codex_bengalfox",
|
||||
"2026-07-10T03:00:03Z",
|
||||
),
|
||||
token_count_with_last_at(
|
||||
1_000,
|
||||
0,
|
||||
10,
|
||||
100,
|
||||
0,
|
||||
10,
|
||||
"codex_spark",
|
||||
"2026-07-10T03:00:04Z",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| event.delta.input)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(deltas, vec![100]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_source_replay_remains_adjacent_across_non_token_events() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context(),
|
||||
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:02Z"),
|
||||
turn_context_for_model_at("gpt-5.6-sol", "2026-07-10T03:00:03Z"),
|
||||
token_count_with_last_at(
|
||||
1_000,
|
||||
0,
|
||||
10,
|
||||
100,
|
||||
0,
|
||||
10,
|
||||
"codex_bengalfox",
|
||||
"2026-07-10T03:00:04Z",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| event.delta.input)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(deltas, vec![100]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_source_repeat_is_deduped_after_another_source_advances() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context(),
|
||||
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:02Z"),
|
||||
token_count_with_last_at(
|
||||
2_000,
|
||||
0,
|
||||
20,
|
||||
100,
|
||||
0,
|
||||
10,
|
||||
"codex_bengalfox",
|
||||
"2026-07-10T03:00:03Z",
|
||||
),
|
||||
// `codex` has not advanced since its X snapshot, so this is a
|
||||
// same-source replay even though another source was interleaved.
|
||||
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:04Z"),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| event.delta.input)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(deltas, vec![100, 100]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stale_cross_source_signature_does_not_swallow_reset() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context(),
|
||||
// `codex` emits snapshot X.
|
||||
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:02Z"),
|
||||
// X is replayed under another rate-limit source.
|
||||
token_count_with_last_at(
|
||||
1_000,
|
||||
0,
|
||||
10,
|
||||
100,
|
||||
0,
|
||||
10,
|
||||
"codex_bengalfox",
|
||||
"2026-07-10T03:00:03Z",
|
||||
),
|
||||
// The original source advances to Y.
|
||||
token_count_with_last_at(2_000, 0, 20, 100, 0, 10, "codex", "2026-07-10T03:00:04Z"),
|
||||
// A genuine reset later reproduces X. The stale copy retained
|
||||
// by `codex_bengalfox` must not classify this as a replay.
|
||||
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:05Z"),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| event.delta.input)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(deltas, vec![100, 100, 100]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_snapshot_dedupe_allows_counter_reset() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
let first =
|
||||
token_count_with_last_at(100, 50, 10, 100, 50, 10, "codex", "2026-07-10T03:00:02Z");
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context(),
|
||||
first.clone(),
|
||||
first,
|
||||
token_count_with_last_at(
|
||||
200,
|
||||
100,
|
||||
20,
|
||||
100,
|
||||
50,
|
||||
10,
|
||||
"codex",
|
||||
"2026-07-10T03:00:04Z",
|
||||
),
|
||||
// A restarted counter may legitimately return to an older
|
||||
// total after another full snapshot has advanced the source.
|
||||
token_count_with_last_at(100, 50, 10, 50, 25, 5, "codex", "2026-07-10T03:00:05Z"),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| event.delta.input)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(deltas, vec![100, 100, 50]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_last_usage_falls_back_to_total() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context(),
|
||||
serde_json::json!({
|
||||
"timestamp": "2026-07-10T03:00:02Z",
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "token_count",
|
||||
"info": {
|
||||
"total_token_usage": {
|
||||
"input_tokens": 100,
|
||||
"cached_input_tokens": 0,
|
||||
"output_tokens": 10,
|
||||
"reasoning_output_tokens": 0,
|
||||
"total_tokens": 110
|
||||
},
|
||||
"last_token_usage": {}
|
||||
}
|
||||
}
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| event.delta.input)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(deltas, vec![100]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_total_does_not_enable_snapshot_deduplication() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
let event = |limit_id: &str, timestamp: &str| {
|
||||
serde_json::json!({
|
||||
"timestamp": timestamp,
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "token_count",
|
||||
"info": {
|
||||
"total_token_usage": {},
|
||||
"last_token_usage": {
|
||||
"input_tokens": 100,
|
||||
"cached_input_tokens": 0,
|
||||
"output_tokens": 10,
|
||||
"reasoning_output_tokens": 0,
|
||||
"total_tokens": 110
|
||||
}
|
||||
},
|
||||
"rate_limits": { "limit_id": limit_id }
|
||||
}
|
||||
})
|
||||
};
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context(),
|
||||
event("codex", "2026-07-10T03:00:02Z"),
|
||||
// Without a usable cumulative total, identical per-request
|
||||
// usage is not enough evidence that this is a replay.
|
||||
event("codex_bengalfox", "2026-07-10T03:00:03Z"),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| event.delta.input)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(deltas, vec![100, 100]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_fallback_uses_session_baseline_across_model_switch() -> Result<(), AppError> {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = rollout_path(dir.path(), PARENT_ID);
|
||||
write_jsonl(
|
||||
&file,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
turn_context_for_model_at("model-a", "2026-07-10T03:00:01Z"),
|
||||
token_count_at(100, 50, 10, "2026-07-10T03:00:02Z"),
|
||||
turn_context_for_model_at("model-b", "2026-07-10T03:00:03Z"),
|
||||
token_count_at(150, 75, 15, "2026-07-10T03:00:04Z"),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
|
||||
let deltas = parsed
|
||||
.token_events
|
||||
.iter()
|
||||
.filter(|event| !event.delta.is_zero())
|
||||
.map(|event| event.delta.input)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(deltas, vec![100, 50]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cumulative_tokens_valid() {
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
@@ -2061,17 +1535,6 @@ mod tests {
|
||||
assert!(parse_cumulative_tokens(&json).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cumulative_tokens_rejects_empty_object_but_accepts_explicit_zero() {
|
||||
assert!(parse_cumulative_tokens(&serde_json::json!({})).is_none());
|
||||
|
||||
let tokens = parse_cumulative_tokens(&serde_json::json!({ "input_tokens": 0 }))
|
||||
.expect("an explicit zero is valid usage");
|
||||
assert_eq!(tokens.input, 0);
|
||||
assert_eq!(tokens.cached_input, 0);
|
||||
assert_eq!(tokens.output, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cumulative_tokens_alt_field_names() {
|
||||
// 某些版本可能使用 cache_read_input_tokens 而非 cached_input_tokens
|
||||
|
||||
@@ -129,45 +129,20 @@ pub fn sync_grokbuild_usage(db: &Database) -> Result<SessionSyncResult, AppError
|
||||
fn collect_grok_updates_files() -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
for root in crate::session_manager::providers::grokbuild::session_roots() {
|
||||
collect_files_named(&root, "updates.jsonl", &mut files, 0);
|
||||
collect_files_named(&root, "updates.jsonl", &mut files);
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// 单个 updates.jsonl 文件读取上限(50 MiB)。JSONL 单行事件通常几 KiB,
|
||||
/// 正常活跃会话数月也到不了这个量级;超过则视为异常/恶意文件,跳过。
|
||||
const MAX_GROK_FILE_BYTES: u64 = 50 * 1024 * 1024;
|
||||
/// 递归收集 session 日志时的最大目录深度,防止 symlink 循环导致栈溢出。
|
||||
const MAX_COLLECT_DEPTH: usize = 16;
|
||||
|
||||
/// 递归收集目录下指定文件名的文件(容忍布局深度变化,对齐会话浏览器的做法)
|
||||
fn collect_files_named(root: &Path, name: &str, files: &mut Vec<PathBuf>, depth: usize) {
|
||||
if depth > MAX_COLLECT_DEPTH {
|
||||
log::warn!(
|
||||
"Grok session directory traversal exceeded max depth {} at {}",
|
||||
MAX_COLLECT_DEPTH,
|
||||
root.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
fn collect_files_named(root: &Path, name: &str, files: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// `entry.metadata()` 不跟随符号链接(不同于 `path.is_dir()`),这里据此
|
||||
// **无条件跳过一切 symlink**:目录 symlink 不递归(避免循环),文件
|
||||
// symlink 也不收集——同名文件若经 symlink 指向 sessions 根之外,会把用户
|
||||
// 意料之外的内容当作会话日志读入。代价:把 sessions 目录整体做成 symlink
|
||||
// 的用户会同步不到数据,所以跳过必须留日志,便于排查"用量数据静默缺失"。
|
||||
let metadata = entry.metadata();
|
||||
if metadata.as_ref().map(|m| m.is_symlink()).unwrap_or(false) {
|
||||
log::info!("[GROK-SYNC] 跳过符号链接(不跟随): {}", path.display());
|
||||
continue;
|
||||
}
|
||||
let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
|
||||
if is_dir {
|
||||
collect_files_named(&path, name, files, depth + 1);
|
||||
if path.is_dir() {
|
||||
collect_files_named(&path, name, files);
|
||||
} else if path.file_name().and_then(|n| n.to_str()) == Some(name) {
|
||||
files.push(path);
|
||||
}
|
||||
@@ -182,16 +157,6 @@ fn sync_single_grok_file(db: &Database, file_path: &Path) -> Result<SessionSyncR
|
||||
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
|
||||
let file_modified = metadata_modified_nanos(&metadata);
|
||||
|
||||
// 异常大文件直接跳过,避免一次性读取耗尽内存。
|
||||
if metadata.len() > MAX_GROK_FILE_BYTES {
|
||||
log::warn!(
|
||||
"Grok session log too large ({} bytes), skipping: {}",
|
||||
metadata.len(),
|
||||
file_path.display()
|
||||
);
|
||||
return Ok(SessionSyncResult::default());
|
||||
}
|
||||
|
||||
let (last_modified, _last_offset) = get_sync_state(db, &file_path_str)?;
|
||||
if file_modified <= last_modified {
|
||||
return Ok(SessionSyncResult::default());
|
||||
@@ -1183,52 +1148,4 @@ mod tests {
|
||||
assert_eq!(Decimal::from_str(&total).expect("decimal"), expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_updates_jsonl_is_skipped_without_reading_into_memory() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = write_session_file(temp.path(), "sess-huge", &[]);
|
||||
|
||||
// 制造一个超过 50 MiB 的文件,但内容为空(不会被解析)。
|
||||
let huge = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
.expect("open");
|
||||
huge.set_len(MAX_GROK_FILE_BYTES + 1).expect("set_len");
|
||||
drop(huge);
|
||||
|
||||
let result = sync_single_grok_file(&db, &path).expect("sync should not fail");
|
||||
assert_eq!(result.imported, 0, "oversized file must not be imported");
|
||||
assert_eq!(result.skipped, 0);
|
||||
assert_eq!(result.deferred_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symlink_cycle_does_not_cause_stack_overflow() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sessions = temp.path().join("sessions");
|
||||
let enc = sessions.join("enc-project");
|
||||
let sub = enc.join("sub");
|
||||
std::fs::create_dir_all(&sub).expect("create dirs");
|
||||
|
||||
// 构造循环:sub/cycle -> enc 父目录
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&enc, sub.join("cycle")).expect("symlink");
|
||||
#[cfg(windows)]
|
||||
std::os::windows::fs::symlink_dir(&enc, sub.join("cycle")).expect("symlink");
|
||||
|
||||
// 也放一个真实的目标文件,确认正常遍历仍工作
|
||||
std::fs::write(enc.join("updates.jsonl"), b"{}\n").expect("write real file");
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_files_named(&sessions, "updates.jsonl", &mut files, 0);
|
||||
|
||||
assert_eq!(
|
||||
files.len(),
|
||||
1,
|
||||
"only the real updates.jsonl should be collected; symlink cycle must not crash"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-182
@@ -1038,23 +1038,6 @@ impl SkillService {
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
/// 持久化更新后的 Skill 元数据,并重新读取数据库中的权威应用启用状态。
|
||||
///
|
||||
/// 更新过程包含网络下载,期间用户可能切换启用状态或卸载 Skill。这里必须
|
||||
/// 使用只更新现有记录的 DAO,避免旧快照覆盖 `enabled_*`,也避免已卸载记录
|
||||
/// 被重新插入。
|
||||
fn persist_updated_skill_metadata(
|
||||
db: &Arc<Database>,
|
||||
updated_skill: &InstalledSkill,
|
||||
) -> Result<InstalledSkill> {
|
||||
if !db.update_skill_metadata(updated_skill)? {
|
||||
return Err(anyhow!("Skill no longer installed: {}", updated_skill.id));
|
||||
}
|
||||
|
||||
db.get_installed_skill(&updated_skill.id)?
|
||||
.ok_or_else(|| anyhow!("Skill no longer installed: {}", updated_skill.id))
|
||||
}
|
||||
|
||||
/// 更新单个 Skill(重新下载并替换本地文件)
|
||||
pub async fn update_skill(&self, db: &Arc<Database>, skill_id: &str) -> Result<InstalledSkill> {
|
||||
let skill = db
|
||||
@@ -1130,23 +1113,6 @@ impl SkillService {
|
||||
))
|
||||
})?;
|
||||
|
||||
// 下载和扫描期间用户可能已经卸载了该 Skill。必须在任何备份、删除或
|
||||
// 复制之前重新确认记录仍存在;否则即使最终的 metadata UPDATE 能发现
|
||||
// 缺行,这里也会先把已卸载的 SSOT 目录重新创建出来。
|
||||
let current_skill = db
|
||||
.get_installed_skill(&skill.id)?
|
||||
.ok_or_else(|| anyhow!("Skill no longer installed: {}", skill.id))?;
|
||||
if current_skill.directory != skill.directory
|
||||
|| current_skill.repo_owner != skill.repo_owner
|
||||
|| current_skill.repo_name != skill.repo_name
|
||||
|| current_skill.repo_branch != skill.repo_branch
|
||||
|| current_skill.installed_at != skill.installed_at
|
||||
{
|
||||
return Err(anyhow!("Skill changed during update: {}", skill.id));
|
||||
}
|
||||
Self::require_valid_directory(¤t_skill.directory)?;
|
||||
let skill = current_skill;
|
||||
|
||||
// 备份旧文件
|
||||
let _ = Self::create_uninstall_backup(&skill);
|
||||
|
||||
@@ -1170,7 +1136,7 @@ impl SkillService {
|
||||
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
|
||||
let readme_url = Self::build_skill_doc_url(&owner, &name, &used_branch, &doc_path);
|
||||
|
||||
let updated_metadata = InstalledSkill {
|
||||
let updated_skill = InstalledSkill {
|
||||
id: skill.id.clone(),
|
||||
name: new_name,
|
||||
description: new_description,
|
||||
@@ -1185,7 +1151,7 @@ impl SkillService {
|
||||
updated_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
|
||||
let updated_skill = Self::persist_updated_skill_metadata(db, &updated_metadata)?;
|
||||
db.save_skill(&updated_skill)?;
|
||||
|
||||
// 同步到所有已启用的应用目录
|
||||
for app in updated_skill.apps.enabled_apps() {
|
||||
@@ -2408,38 +2374,31 @@ impl SkillService {
|
||||
|
||||
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
|
||||
///
|
||||
/// **核心原则:返回的目录必定含 `SKILL.md`**(以 SKILL.md 为锚点)。解析顺序:
|
||||
/// 1. 直接相对路径命中(如 `skills/foo`),校验含 `SKILL.md`——明确路径优先;
|
||||
/// 2. 按安装名递归查找名字匹配 **且** 含 `SKILL.md` 的目录;
|
||||
/// 3. 兜底:仓库根本身含 `SKILL.md`。
|
||||
/// 兼容三种情况:
|
||||
/// 1. `skills/foo` 这类直接相对路径;
|
||||
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
|
||||
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录。
|
||||
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
|
||||
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
|
||||
let install_name = source_rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())?;
|
||||
|
||||
// 1. 直接相对路径命中(明确路径优先)——必须校验 SKILL.md,否则同名空壳目录
|
||||
// (如 ast-grep/agent-skill 根下的 plugin 包目录 ast-grep/)会被误判为源目录。
|
||||
let direct = root.join(&source_rel);
|
||||
if direct.is_dir() && direct.join("SKILL.md").is_file() {
|
||||
if direct.is_dir() {
|
||||
return Some(direct);
|
||||
}
|
||||
|
||||
// 2. 按名字递归查找(find_skill_dir_by_name 已校验 SKILL.md)
|
||||
if let Some(found) = Self::find_skill_dir_by_name(root, &install_name) {
|
||||
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
|
||||
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
|
||||
log::info!(
|
||||
"Skill directory '{}' not found at direct path, using fallback: {}",
|
||||
install_name,
|
||||
target_name,
|
||||
found.display()
|
||||
);
|
||||
return Some(found);
|
||||
}
|
||||
|
||||
// 3. 兜底:仓库根本身是 skill
|
||||
if root.join("SKILL.md").is_file() {
|
||||
if root.is_dir() && root.join("SKILL.md").exists() {
|
||||
log::info!(
|
||||
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
|
||||
install_name,
|
||||
target_name,
|
||||
);
|
||||
return Some(root.to_path_buf());
|
||||
}
|
||||
@@ -4200,65 +4159,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_updated_skill_metadata_uses_database_apps() {
|
||||
let db = Arc::new(Database::memory().expect("memory db"));
|
||||
let mut installed = poisoned_skill("owner/repo:skill", "skill");
|
||||
installed.name = "old name".to_string();
|
||||
installed.apps = SkillApps::only(&AppType::Claude);
|
||||
db.save_skill(&installed).expect("seed skill");
|
||||
|
||||
// 模拟下载期间用户将 Skill 从 Claude 切换到 Codex。待写入的 metadata
|
||||
// 仍携带下载开始时的旧 apps 快照。
|
||||
let authoritative_apps = SkillApps::only(&AppType::Codex);
|
||||
db.update_skill_apps(&installed.id, &authoritative_apps)
|
||||
.expect("toggle apps");
|
||||
|
||||
let mut updated_metadata = installed.clone();
|
||||
updated_metadata.name = "new name".to_string();
|
||||
updated_metadata.content_hash = Some("new hash".to_string());
|
||||
updated_metadata.updated_at = 42;
|
||||
|
||||
let persisted = SkillService::persist_updated_skill_metadata(&db, &updated_metadata)
|
||||
.expect("persist metadata");
|
||||
|
||||
assert_eq!(persisted.name, "new name");
|
||||
assert_eq!(persisted.content_hash.as_deref(), Some("new hash"));
|
||||
assert_eq!(persisted.updated_at, 42);
|
||||
assert_eq!(persisted.apps, authoritative_apps);
|
||||
assert_eq!(
|
||||
db.get_installed_skill(&installed.id)
|
||||
.expect("query skill")
|
||||
.expect("skill remains installed")
|
||||
.apps,
|
||||
authoritative_apps
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_updated_skill_metadata_does_not_restore_uninstalled_skill() {
|
||||
let db = Arc::new(Database::memory().expect("memory db"));
|
||||
let installed = poisoned_skill("owner/repo:skill", "skill");
|
||||
db.save_skill(&installed).expect("seed skill");
|
||||
|
||||
// 模拟下载期间卸载完成,随后旧的更新任务才尝试落库。
|
||||
assert!(db.delete_skill(&installed.id).expect("uninstall skill"));
|
||||
|
||||
let mut updated_metadata = installed.clone();
|
||||
updated_metadata.name = "downloaded update".to_string();
|
||||
let err = SkillService::persist_updated_skill_metadata(&db, &updated_metadata)
|
||||
.expect_err("an uninstalled skill must not be restored");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("Skill no longer installed"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(db
|
||||
.get_installed_skill(&installed.id)
|
||||
.expect("query skill")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_valid_directory_accepts_single_segment_names_only() {
|
||||
assert_eq!(
|
||||
@@ -4552,74 +4452,4 @@ mod tests {
|
||||
"existing destination skill should be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skill_source_dir_rejects_same_name_wrapper_without_skill_md() {
|
||||
// 复刻 issue #4141:ast-grep/agent-skill 结构。仓库根下有同名目录 ast-grep/
|
||||
// (plugin 包,无 SKILL.md),真正的 skill 在 ast-grep/skills/ast-grep/SKILL.md。
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let wrapper = temp.path().join("ast-grep");
|
||||
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
|
||||
fs::write(
|
||||
wrapper.join(".claude-plugin").join("plugin.json"),
|
||||
"{\"name\":\"ast-grep\"}",
|
||||
)
|
||||
.expect("write plugin.json");
|
||||
let real_skill = wrapper.join("skills").join("ast-grep");
|
||||
write_skill(&real_skill, "ast-grep");
|
||||
|
||||
// directory 只给了 skill 名 "ast-grep"(skills.sh API 的语义),不能命中空壳 wrapper。
|
||||
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep")
|
||||
.expect("should resolve to the inner skill dir, not the same-name wrapper");
|
||||
|
||||
assert_eq!(resolved, real_skill);
|
||||
assert!(resolved.join("SKILL.md").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skill_source_dir_finds_two_level_catalog_skill() {
|
||||
// catalog layout:skills/category/foo/SKILL.md(depth 3,find_skill_dir_by_name 可达)。
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let catalog_skill = temp.path().join("skills").join("category").join("foo");
|
||||
write_skill(&catalog_skill, "Foo Skill");
|
||||
|
||||
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo")
|
||||
.expect("should resolve the two-level catalog skill by name");
|
||||
|
||||
assert_eq!(resolved, catalog_skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skill_source_dir_returns_none_for_wrapper_without_inner_skill() {
|
||||
// 同名 wrapper 存在、无 SKILL.md,且无 inner skill / root SKILL.md 可兜底时,
|
||||
// 必须返回 None——守住 #4141 这个 bug class 的负例(不能把空壳目录当源目录)。
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let wrapper = temp.path().join("ast-grep");
|
||||
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
|
||||
fs::write(
|
||||
wrapper.join(".claude-plugin").join("plugin.json"),
|
||||
"{\"name\":\"ast-grep\"}",
|
||||
)
|
||||
.expect("write plugin.json");
|
||||
|
||||
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep");
|
||||
assert!(
|
||||
resolved.is_none(),
|
||||
"wrapper dir without SKILL.md and no inner skill must resolve to None, got {:?}",
|
||||
resolved
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skill_source_dir_returns_none_when_no_skill_md_anywhere() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
fs::create_dir_all(temp.path().join("skills").join("foo")).expect("create empty skill dir");
|
||||
fs::write(temp.path().join("README.md"), "no skills here").expect("write README");
|
||||
|
||||
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo");
|
||||
assert!(
|
||||
resolved.is_none(),
|
||||
"no SKILL.md anywhere must resolve to None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,7 @@ pub async fn execute_usage_script(
|
||||
}
|
||||
|
||||
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||
// 用量脚本允许的最长执行时间(秒)。脚本来自不可信来源(deeplink、同步导入),
|
||||
// 必须限制其 CPU / 内存 / 栈占用,防止一个恶意/ buggy 脚本挂死整个后端。
|
||||
const USAGE_SCRIPT_TIMEOUT_SECS: u64 = 5;
|
||||
// 16 MiB 对仅构造 request 配置 / extractor 的脚本已经足够。
|
||||
const USAGE_SCRIPT_MEMORY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// 创建一个受控的 QuickJS Runtime:限制内存与栈,并安装执行时间中断器。
|
||||
fn create_script_runtime() -> Result<Runtime, AppError> {
|
||||
let request_config = {
|
||||
let runtime = Runtime::new().map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.runtime_create_failed",
|
||||
@@ -45,29 +38,6 @@ pub async fn execute_usage_script(
|
||||
format!("Failed to create JS runtime: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 内存和栈限制必须在 eval 前设置。
|
||||
runtime.set_memory_limit(USAGE_SCRIPT_MEMORY_LIMIT_BYTES);
|
||||
// set_max_stack_size 默认 256 KiB 够用,这里显式重申请求它保持一致。
|
||||
runtime.set_max_stack_size(256 * 1024);
|
||||
|
||||
// 时间片中断器:每轮解释器循环检查是否超时,超时则抛出不可捕获的异常。
|
||||
let deadline = std::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(USAGE_SCRIPT_TIMEOUT_SECS))
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"usage_script.invalid_timeout",
|
||||
"无法计算脚本执行截止时间",
|
||||
"Unable to compute script execution deadline",
|
||||
)
|
||||
})?;
|
||||
runtime.set_interrupt_handler(Some(Box::new(move || std::time::Instant::now() > deadline)));
|
||||
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
let request_config = {
|
||||
let runtime = create_script_runtime()?;
|
||||
let context = Context::full(&runtime).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.context_create_failed",
|
||||
@@ -142,7 +112,13 @@ pub async fn execute_usage_script(
|
||||
|
||||
// 7. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
||||
let result: Value = {
|
||||
let runtime = create_script_runtime()?;
|
||||
let runtime = Runtime::new().map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.runtime_create_failed",
|
||||
format!("创建 JS 运行时失败: {e}"),
|
||||
format!("Failed to create JS runtime: {e}"),
|
||||
)
|
||||
})?;
|
||||
let context = Context::full(&runtime).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.context_create_failed",
|
||||
@@ -687,42 +663,4 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infinite_loop_usage_script_is_interrupted_before_blocking_the_backend() {
|
||||
// 用量脚本来自不可信输入(deeplink / 同步导入的 DB 行),必须限制 CPU 时间,
|
||||
// 否则 `while(true)` 会挂死执行线程(DoS)。
|
||||
let script = r#"
|
||||
(function(){
|
||||
while (true) { Math.sqrt(Math.random()); }
|
||||
})();
|
||||
({ request: { url: "https://example.com", method: "GET" } })
|
||||
"#;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("tokio runtime for test")
|
||||
.block_on(execute_usage_script(
|
||||
script,
|
||||
"sk-test",
|
||||
"https://api.example.com",
|
||||
30,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"infinite loop script must be rejected, got: {result:?}"
|
||||
);
|
||||
// 必须明显短于无限等待;留足余量避免 CI 抖动,但应远小于 30 秒网络超时。
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(15),
|
||||
"interruption took too long: {elapsed:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-78
@@ -25,8 +25,6 @@ import {
|
||||
Shield,
|
||||
Cpu,
|
||||
LayoutDashboard,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
@@ -78,9 +76,7 @@ import {
|
||||
getSkillsPageHeaderActions,
|
||||
type SkillsPageSource,
|
||||
} from "@/components/skills/SkillsPage";
|
||||
import UnifiedSkillsPanel, {
|
||||
type SkillsCheckUpdatesState,
|
||||
} from "@/components/skills/UnifiedSkillsPanel";
|
||||
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog";
|
||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||
@@ -183,16 +179,6 @@ function App() {
|
||||
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
|
||||
const [mcpManagementBusy, setMcpManagementBusy] = useState(false);
|
||||
const [skillsManagementBusy, setSkillsManagementBusy] = useState(false);
|
||||
const [skillsNavigationBusy, setSkillsNavigationBusy] = useState(false);
|
||||
const [promptManagementBusy, setPromptManagementBusy] = useState(false);
|
||||
const [promptNavigationBusy, setPromptNavigationBusy] = useState(false);
|
||||
const [skillsCheckUpdatesState, setSkillsCheckUpdatesState] =
|
||||
useState<SkillsCheckUpdatesState>({
|
||||
isChecking: false,
|
||||
hasSkills: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
|
||||
@@ -604,10 +590,6 @@ function App() {
|
||||
}, [activeApp]);
|
||||
|
||||
const currentViewRef = useRef(currentView);
|
||||
const managementBusy =
|
||||
mcpManagementBusy || skillsNavigationBusy || promptNavigationBusy;
|
||||
const managementBusyRef = useRef(false);
|
||||
managementBusyRef.current = managementBusy;
|
||||
|
||||
useEffect(() => {
|
||||
currentViewRef.current = currentView;
|
||||
@@ -616,10 +598,6 @@ function App() {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
|
||||
if (managementBusyRef.current) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setCurrentView("settings");
|
||||
return;
|
||||
@@ -631,7 +609,6 @@ function App() {
|
||||
|
||||
const view = currentViewRef.current;
|
||||
if (view === "providers") return;
|
||||
if (managementBusyRef.current) return;
|
||||
|
||||
if (isTextEditableTarget(event.target)) return;
|
||||
|
||||
@@ -931,8 +908,6 @@ function App() {
|
||||
open={true}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
appId={sharedFeatureApp}
|
||||
onInteractionBlockedChange={setPromptManagementBusy}
|
||||
onNavigationBlockedChange={setPromptNavigationBusy}
|
||||
/>
|
||||
);
|
||||
case "hermesMemory":
|
||||
@@ -942,9 +917,6 @@ function App() {
|
||||
<UnifiedSkillsPanel
|
||||
ref={unifiedSkillsPanelRef}
|
||||
onOpenDiscovery={handleOpenSkillsDiscovery}
|
||||
onInteractionBlockedChange={setSkillsManagementBusy}
|
||||
onNavigationBlockedChange={setSkillsNavigationBusy}
|
||||
onCheckUpdatesStateChange={setSkillsCheckUpdatesState}
|
||||
currentApp={
|
||||
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
||||
}
|
||||
@@ -965,7 +937,6 @@ function App() {
|
||||
<UnifiedMcpPanel
|
||||
ref={mcpPanelRef}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
onInteractionBlockedChange={setMcpManagementBusy}
|
||||
/>
|
||||
);
|
||||
case "agents":
|
||||
@@ -1184,7 +1155,6 @@ function App() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={managementBusy}
|
||||
onClick={() =>
|
||||
setCurrentView(
|
||||
currentView === "skillsDiscovery"
|
||||
@@ -1192,10 +1162,7 @@ function App() {
|
||||
: "providers",
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"mr-2 rounded-lg",
|
||||
managementBusy && "disabled:opacity-100",
|
||||
)}
|
||||
className="mr-2 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1308,7 +1275,7 @@ function App() {
|
||||
<ProfileSwitcher activeApp={activeApp} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-w-0 overflow-x-hidden items-center py-4">
|
||||
<div className="flex flex-1 min-w-0 overflow-x-hidden items-center py-4 pr-2">
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5 ml-auto"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
@@ -1317,9 +1284,8 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={promptManagementBusy}
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 disabled:opacity-100 dark:hover:bg-white/5"
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("prompts.add")}
|
||||
@@ -1330,9 +1296,8 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={mcpManagementBusy}
|
||||
onClick={() => mcpPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 disabled:opacity-100 dark:hover:bg-white/5"
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("mcp.importExisting")}
|
||||
@@ -1340,9 +1305,8 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={mcpManagementBusy}
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 disabled:opacity-100 dark:hover:bg-white/5"
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("mcp.addMcp")}
|
||||
@@ -1354,36 +1318,10 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={
|
||||
skillsManagementBusy ||
|
||||
skillsCheckUpdatesState.isChecking ||
|
||||
!skillsCheckUpdatesState.hasSkills
|
||||
}
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.checkUpdates()
|
||||
}
|
||||
className={cn(
|
||||
"hover:bg-black/5 dark:hover:bg-white/5",
|
||||
skillsManagementBusy && "disabled:opacity-100",
|
||||
)}
|
||||
>
|
||||
{skillsCheckUpdatesState.isChecking ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{skillsCheckUpdatesState.isChecking
|
||||
? t("skills.checkingUpdates")
|
||||
: t("skills.checkUpdates")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={skillsManagementBusy}
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openRestoreFromBackup()
|
||||
}
|
||||
className="hover:bg-black/5 disabled:opacity-100 dark:hover:bg-white/5"
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
{t("skills.restoreFromBackup.button")}
|
||||
@@ -1391,11 +1329,10 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={skillsManagementBusy}
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
||||
}
|
||||
className="hover:bg-black/5 disabled:opacity-100 dark:hover:bg-white/5"
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<FolderArchive className="w-4 h-4 mr-2" />
|
||||
{t("skills.installFromZip.button")}
|
||||
@@ -1403,11 +1340,10 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={skillsManagementBusy}
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openImport()
|
||||
}
|
||||
className="relative hover:bg-black/5 disabled:opacity-100 dark:hover:bg-white/5"
|
||||
className="relative hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={
|
||||
hasUnmanagedSkills
|
||||
? t("skills.unmanagedAvailable")
|
||||
@@ -1426,11 +1362,8 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={skillsManagementBusy}
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openDiscovery()
|
||||
}
|
||||
className="hover:bg-black/5 disabled:opacity-100 dark:hover:bg-white/5"
|
||||
onClick={handleOpenSkillsDiscovery}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
{t("skills.discover")}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCodexOauthQuotaByAccountId } from "@/lib/query/subscription";
|
||||
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
|
||||
|
||||
interface CodexOauthAccountQuotaProps {
|
||||
/** cc-switch 自管的 ChatGPT 账号 ID */
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 → 认证中心里,单个 ChatGPT (Codex OAuth) 账号的用量展示。
|
||||
*
|
||||
* 直接按 accountId 查询 cc-switch 自管 OAuth token 的订阅额度,复用
|
||||
* `SubscriptionQuotaView` 的展开布局(进度条 + 重置倒计时 + 刷新按钮),
|
||||
* 因此与供应商卡片里的额度展示保持完全一致的观感与状态处理。
|
||||
*
|
||||
* 面板打开时拉取一次,不轮询;用户可点卡片内的刷新按钮手动更新。
|
||||
*/
|
||||
const CodexOauthAccountQuota: React.FC<CodexOauthAccountQuotaProps> = ({
|
||||
accountId,
|
||||
}) => {
|
||||
const {
|
||||
data: quota,
|
||||
isFetching: loading,
|
||||
refetch,
|
||||
} = useCodexOauthQuotaByAccountId(accountId, {
|
||||
enabled: true,
|
||||
autoQuery: false,
|
||||
});
|
||||
|
||||
// 首次加载占位:账号头部由父组件独立渲染,这里只负责用量区。
|
||||
// 用量请求是异步的(Tauri invoke + React Query),加载期间给一个
|
||||
// 与最终额度卡片同形状(rounded-xl / border / bg-card)的转圈占位,
|
||||
// 这样账号会立刻显示、用量数据到达后原地平滑替换,不产生跳版。
|
||||
if (loading && !quota) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-center rounded-xl border border-border-default bg-card py-5 shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubscriptionQuotaView
|
||||
quota={quota}
|
||||
loading={loading}
|
||||
refetch={refetch}
|
||||
appIdForExpiredHint="codex_oauth"
|
||||
inline={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexOauthAccountQuota;
|
||||
@@ -23,7 +23,6 @@ interface ConfirmDialogProps {
|
||||
/** 可选勾选项:提供 label 即显示,勾选状态经 onConfirm 参数回传 */
|
||||
checkboxLabel?: string;
|
||||
checkboxDefaultChecked?: boolean;
|
||||
pending?: boolean;
|
||||
onConfirm: (checkboxChecked: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -38,7 +37,6 @@ export function ConfirmDialog({
|
||||
zIndex = "alert",
|
||||
checkboxLabel,
|
||||
checkboxDefaultChecked = false,
|
||||
pending = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
@@ -61,7 +59,7 @@ export function ConfirmDialog({
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pending) {
|
||||
if (!open) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
@@ -80,7 +78,6 @@ export function ConfirmDialog({
|
||||
<label className="flex cursor-pointer select-none items-start gap-2 px-6 pt-3">
|
||||
<Checkbox
|
||||
checked={checkboxChecked}
|
||||
disabled={pending}
|
||||
onCheckedChange={(value) => setCheckboxChecked(value === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
@@ -88,12 +85,11 @@ export function ConfirmDialog({
|
||||
</label>
|
||||
) : null}
|
||||
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
|
||||
<Button variant="outline" onClick={onCancel} disabled={pending}>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{cancelText || t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant === "info" ? "default" : "destructive"}
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
// 未渲染勾选框时不得回传 defaultChecked 残留值
|
||||
onConfirm(checkboxLabel ? checkboxChecked : false)
|
||||
|
||||
@@ -590,23 +590,9 @@ export function DeepLinkImportDialog() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Usage Script Configuration (v3.9+)
|
||||
区块门槛必须与后端 `build_provider_meta` 的持久化条件对齐:
|
||||
任一 usage 字段存在即落库。若只按 usageScript 开门,一条仅携带
|
||||
usageAccessToken/usageUserId 等字段的链接会在对话框毫无展示的
|
||||
情况下把凭据写进供应商配置——展示是用户同意机制的承重部分。
|
||||
*/}
|
||||
{(request.usageScript ||
|
||||
request.usageEnabled !== undefined ||
|
||||
request.usageApiKey ||
|
||||
request.usageBaseUrl ||
|
||||
request.usageAccessToken ||
|
||||
request.usageUserId ||
|
||||
request.usageAutoInterval !== undefined) && (
|
||||
{/* Usage Script Configuration (v3.9+) */}
|
||||
{request.usageScript && (
|
||||
<div className="space-y-3 pt-2 border-t border-border-default">
|
||||
{(request.usageScript ||
|
||||
request.usageEnabled !== undefined) && (
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.usageScript", {
|
||||
@@ -636,10 +622,7 @@ export function DeepLinkImportDialog() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{request.usageScript && (
|
||||
<>
|
||||
{/*
|
||||
脚本正文必须完整展示。这段是会执行的 JavaScript,而 payload
|
||||
常常整条藏在中间——`whitespace-pre-wrap break-all` + 可滚动容器,
|
||||
@@ -666,8 +649,6 @@ export function DeepLinkImportDialog() {
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
<span>{t("deeplink.usageScriptWarning")}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Usage API Key (if different from provider) */}
|
||||
{request.usageApiKey &&
|
||||
@@ -701,36 +682,6 @@ export function DeepLinkImportDialog() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Access Token (if present) */}
|
||||
{request.usageAccessToken && (
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.usageAccessToken", {
|
||||
defaultValue: "用量访问令牌",
|
||||
})}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-mono text-muted-foreground">
|
||||
{request.usageAccessToken.length > 4
|
||||
? `${request.usageAccessToken.substring(0, 4)}${"*".repeat(12)}`
|
||||
: "****"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage User ID (if present) */}
|
||||
{request.usageUserId && (
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.usageUserId", {
|
||||
defaultValue: "用量用户 ID",
|
||||
})}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-mono break-all">
|
||||
{request.usageUserId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto Query Interval */}
|
||||
{request.usageAutoInterval &&
|
||||
request.usageAutoInterval > 0 && (
|
||||
|
||||
@@ -1,105 +1,35 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge, badgeVariants } from "@/components/ui/badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AppCountBarProps {
|
||||
totalLabel: string;
|
||||
counts: Partial<Record<AppId, number>>;
|
||||
appIds?: AppId[];
|
||||
totalCount?: number;
|
||||
onToggleAll?: (app: AppId, enabled: boolean) => void | Promise<void>;
|
||||
pendingApp?: AppId | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||
totalLabel,
|
||||
counts,
|
||||
appIds = APP_IDS,
|
||||
totalCount,
|
||||
onToggleAll,
|
||||
pendingApp,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const bulkToggleEnabled = totalCount !== undefined && !!onToggleAll;
|
||||
const bulkTotalCount = totalCount ?? 0;
|
||||
const hasPendingBulkToggle = pendingApp !== undefined && pendingApp !== null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-shrink-0 items-center gap-4 rounded-xl border border-white/10 px-6 py-4 glass">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-7 shrink-0 whitespace-nowrap bg-background/50 px-3"
|
||||
>
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
||||
<Badge variant="outline" className="bg-background/50 h-7 px-3">
|
||||
{totalLabel}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1 overflow-x-auto no-scrollbar">
|
||||
<div className="ml-auto flex w-max min-w-full items-center justify-end gap-2">
|
||||
{appIds.map((app) => {
|
||||
const count = counts[app] ?? 0;
|
||||
const allEnabled =
|
||||
bulkToggleEnabled &&
|
||||
bulkTotalCount > 0 &&
|
||||
count >= bulkTotalCount;
|
||||
const partiallyEnabled =
|
||||
bulkToggleEnabled && count > 0 && count < bulkTotalCount;
|
||||
const pending = pendingApp === app;
|
||||
const actionLabel = allEnabled
|
||||
? t("common.disableAllForApp", { app: APP_ICON_MAP[app].label })
|
||||
: t("common.enableAllForApp", { app: APP_ICON_MAP[app].label });
|
||||
|
||||
if (!bulkToggleEnabled) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
||||
{appIds.map((app) => (
|
||||
<Badge
|
||||
key={app}
|
||||
variant="secondary"
|
||||
className={APP_ICON_MAP[app].badgeClass}
|
||||
>
|
||||
<span className="opacity-75">{APP_ICON_MAP[app].label}:</span>
|
||||
<span className="font-bold ml-1">{count}</span>
|
||||
<span className="font-bold ml-1">{counts[app] ?? 0}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={app}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={partiallyEnabled ? "mixed" : allEnabled}
|
||||
aria-busy={pending}
|
||||
aria-label={actionLabel}
|
||||
title={actionLabel}
|
||||
data-selection-state={
|
||||
pending
|
||||
? "pending"
|
||||
: allEnabled
|
||||
? "all"
|
||||
: partiallyEnabled
|
||||
? "partial"
|
||||
: "none"
|
||||
}
|
||||
disabled={
|
||||
disabled || bulkTotalCount === 0 || hasPendingBulkToggle
|
||||
}
|
||||
onClick={() => void onToggleAll?.(app, !allEnabled)}
|
||||
className={cn(
|
||||
badgeVariants({ variant: "secondary" }),
|
||||
APP_ICON_MAP[app].badgeClass,
|
||||
"shrink-0 cursor-pointer select-none whitespace-nowrap focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed",
|
||||
pending && "cursor-wait disabled:cursor-wait",
|
||||
)}
|
||||
>
|
||||
<span className="opacity-75">{APP_ICON_MAP[app].label}:</span>
|
||||
<span className="ml-1 font-bold">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,14 +11,12 @@ interface AppToggleGroupProps {
|
||||
apps: Partial<Record<AppId, boolean>>;
|
||||
onToggle: (app: AppId, enabled: boolean) => void;
|
||||
appIds?: AppId[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
||||
apps,
|
||||
onToggle,
|
||||
appIds = APP_IDS,
|
||||
disabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
@@ -31,12 +29,9 @@ export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(app, !enabled)}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={Boolean(enabled)}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${
|
||||
enabled ? activeClass : "opacity-35 hover:opacity-70"
|
||||
} disabled:cursor-not-allowed`}
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ManagementListSearchProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
ariaLabel: string;
|
||||
clearLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Shared, presentation-only search field for local management lists. */
|
||||
export function ManagementListSearch({
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
clearLabel,
|
||||
className,
|
||||
}: ManagementListSearchProps) {
|
||||
return (
|
||||
<div role="search" className={cn("relative flex-shrink-0 mb-4", className)}>
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && value) {
|
||||
event.stopPropagation();
|
||||
onValueChange("");
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onValueChange("")}
|
||||
aria-label={clearLabel}
|
||||
title={clearLabel}
|
||||
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,7 +118,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
|
||||
const [configError, setConfigError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savingRef = React.useRef(false);
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [idError, setIdError] = useState("");
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
@@ -289,8 +288,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (savingRef.current) return;
|
||||
|
||||
const trimmedId = formId.trim();
|
||||
if (!trimmedId) {
|
||||
toast.error(t("mcp.error.idRequired"), { duration: 3000 });
|
||||
@@ -360,7 +357,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const nameTrimmed = (formName || trimmedId).trim();
|
||||
@@ -414,7 +410,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
const msg = mapped || detail || t("mcp.error.saveFailed");
|
||||
toast.error(msg, { duration: mapped || detail ? 6000 : 4000 });
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
@@ -428,9 +423,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
<FullScreenPanel
|
||||
isOpen={true}
|
||||
title={getFormTitle()}
|
||||
onClose={() => {
|
||||
if (!savingRef.current) onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Edit3, ExternalLink, Search, Server, Trash2 } from "lucide-react";
|
||||
import { Server } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
useAllMcpServers,
|
||||
useBulkToggleMcpApp,
|
||||
useToggleMcpApp,
|
||||
useDeleteMcpServer,
|
||||
useImportMcpFromApps,
|
||||
@@ -15,6 +13,7 @@ import type { McpServer } from "@/types";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import McpFormModal from "./McpFormModal";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
import { Edit3, Trash2, ExternalLink } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { mcpPresets } from "@/config/mcpPresets";
|
||||
import { toast } from "sonner";
|
||||
@@ -22,37 +21,9 @@ import { MCP_APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
import { ManagementListSearch } from "@/components/common/ManagementListSearch";
|
||||
|
||||
function getMcpSearchText(id: string, server: McpServer): string {
|
||||
const spec = server.server ?? {};
|
||||
const values: unknown[] = [
|
||||
id,
|
||||
server.id,
|
||||
server.name,
|
||||
server.description,
|
||||
...(Array.isArray(server.tags) ? server.tags : []),
|
||||
spec.type,
|
||||
spec.command,
|
||||
...(Array.isArray(spec.args) ? spec.args : []),
|
||||
spec.cwd,
|
||||
spec.url,
|
||||
server.homepage,
|
||||
server.docs,
|
||||
server.source,
|
||||
];
|
||||
|
||||
// Keep this an explicit allow-list. In particular, env and headers may
|
||||
// contain credentials and must never become part of the searchable text.
|
||||
return values
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
interface UnifiedMcpPanelProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onInteractionBlockedChange?: (blocked: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UnifiedMcpPanelHandle {
|
||||
@@ -63,13 +34,10 @@ export interface UnifiedMcpPanelHandle {
|
||||
const UnifiedMcpPanel = React.forwardRef<
|
||||
UnifiedMcpPanelHandle,
|
||||
UnifiedMcpPanelProps
|
||||
>(({ onOpenChange: _onOpenChange, onInteractionBlockedChange }, ref) => {
|
||||
>(({ onOpenChange: _onOpenChange }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [writePending, setWritePending] = useState(false);
|
||||
const writeLockRef = React.useRef(false);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
@@ -79,59 +47,14 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
|
||||
const { data: serversMap, isLoading } = useAllMcpServers();
|
||||
const toggleAppMutation = useToggleMcpApp();
|
||||
const bulkToggleAppMutation = useBulkToggleMcpApp();
|
||||
const deleteServerMutation = useDeleteMcpServer();
|
||||
const importMutation = useImportMcpFromApps();
|
||||
|
||||
const mutationPending =
|
||||
toggleAppMutation.isPending ||
|
||||
bulkToggleAppMutation.isPending ||
|
||||
deleteServerMutation.isPending ||
|
||||
importMutation.isPending;
|
||||
const interactionBlocked =
|
||||
writePending || mutationPending || isFormOpen || confirmDialog !== null;
|
||||
|
||||
React.useEffect(() => {
|
||||
onInteractionBlockedChange?.(interactionBlocked);
|
||||
}, [interactionBlocked, onInteractionBlockedChange]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => onInteractionBlockedChange?.(false),
|
||||
[onInteractionBlockedChange],
|
||||
);
|
||||
|
||||
const beginWrite = (allowOpenConfirmation = false) => {
|
||||
if (
|
||||
writeLockRef.current ||
|
||||
mutationPending ||
|
||||
isFormOpen ||
|
||||
(!allowOpenConfirmation && confirmDialog !== null)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
writeLockRef.current = true;
|
||||
setWritePending(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const endWrite = () => {
|
||||
writeLockRef.current = false;
|
||||
setWritePending(false);
|
||||
};
|
||||
|
||||
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
||||
if (!serversMap) return [];
|
||||
return Object.entries(serversMap);
|
||||
}, [serversMap]);
|
||||
|
||||
const normalizedSearchQuery = searchQuery.trim().toLowerCase();
|
||||
const filteredServerEntries = useMemo(() => {
|
||||
if (!normalizedSearchQuery) return serverEntries;
|
||||
return serverEntries.filter(([id, server]) =>
|
||||
getMcpSearchText(id, server).includes(normalizedSearchQuery),
|
||||
);
|
||||
}, [normalizedSearchQuery, serverEntries]);
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = {
|
||||
claude: 0,
|
||||
@@ -151,76 +74,29 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
return counts;
|
||||
}, [serverEntries]);
|
||||
|
||||
const pendingApp = bulkToggleAppMutation.isPending
|
||||
? (bulkToggleAppMutation.variables?.app ?? null)
|
||||
: toggleAppMutation.isPending
|
||||
? (toggleAppMutation.variables?.app ?? null)
|
||||
: null;
|
||||
|
||||
const handleToggleApp = async (
|
||||
serverId: string,
|
||||
app: AppId,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
if (!beginWrite()) return;
|
||||
try {
|
||||
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAll = async (app: AppId, enabled: boolean) => {
|
||||
if (!beginWrite()) return;
|
||||
|
||||
// AppCountBar summarizes the complete collection, so its bulk action must
|
||||
// use the complete collection too, even while a search filter is active.
|
||||
const serverIds = serverEntries
|
||||
.filter(([_, server]) => Boolean(server.apps[app]) !== enabled)
|
||||
.map(([id]) => id);
|
||||
if (serverIds.length === 0) {
|
||||
endWrite();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bulkToggleAppMutation.mutateAsync({
|
||||
serverIds,
|
||||
app,
|
||||
enabled,
|
||||
});
|
||||
if (result.failed.length > 0) {
|
||||
toast.error(
|
||||
t("common.bulkToggleFailed", { count: result.failed.length }),
|
||||
{ closeButton: true },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.bulkToggleFailed", { count: serverIds.length }), {
|
||||
description: String(error),
|
||||
closeButton: true,
|
||||
});
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
if (writeLockRef.current || interactionBlocked) return;
|
||||
setEditingId(id);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
if (writeLockRef.current || interactionBlocked) return;
|
||||
setEditingId(null);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!beginWrite()) return;
|
||||
try {
|
||||
const count = await importMutation.mutateAsync();
|
||||
if (count === 0) {
|
||||
@@ -234,8 +110,6 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -245,21 +119,17 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
}));
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (writeLockRef.current || interactionBlocked) return;
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: t("mcp.unifiedPanel.deleteServer"),
|
||||
message: t("mcp.unifiedPanel.deleteConfirm", { id }),
|
||||
onConfirm: async () => {
|
||||
if (!beginWrite(true)) return;
|
||||
try {
|
||||
await deleteServerMutation.mutateAsync(id);
|
||||
setConfirmDialog(null);
|
||||
toast.success(t("common.success"), { closeButton: true });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -276,22 +146,9 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||
counts={enabledCounts}
|
||||
appIds={MCP_APP_IDS}
|
||||
totalCount={serverEntries.length}
|
||||
onToggleAll={handleToggleAll}
|
||||
pendingApp={pendingApp}
|
||||
disabled={interactionBlocked}
|
||||
/>
|
||||
|
||||
<ManagementListSearch
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={t("mcp.unifiedPanel.searchPlaceholder")}
|
||||
ariaLabel={t("mcp.unifiedPanel.searchAriaLabel")}
|
||||
clearLabel={t("common.clear")}
|
||||
/>
|
||||
|
||||
<ScrollArea type="auto" className="-mr-3 flex-1 min-h-0">
|
||||
<div className="pb-24 pr-3">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t("mcp.loading")}
|
||||
@@ -308,15 +165,10 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
{t("mcp.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredServerEntries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground">
|
||||
<Search className="mb-4 h-10 w-10 opacity-40" />
|
||||
<p className="text-sm">{t("mcp.unifiedPanel.noSearchResults")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="rounded-xl border border-border-default overflow-hidden">
|
||||
{filteredServerEntries.map(([id, server], index) => (
|
||||
{serverEntries.map(([id, server], index) => (
|
||||
<UnifiedMcpListItem
|
||||
key={id}
|
||||
id={id}
|
||||
@@ -324,15 +176,13 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
onToggleApp={handleToggleApp}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
disabled={interactionBlocked}
|
||||
isLast={index === filteredServerEntries.length - 1}
|
||||
isLast={index === serverEntries.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{isFormOpen && (
|
||||
<McpFormModal
|
||||
@@ -355,7 +205,6 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
pending={writePending}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
@@ -372,7 +221,6 @@ interface UnifiedMcpListItemProps {
|
||||
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
@@ -382,7 +230,6 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
onToggleApp,
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled,
|
||||
isLast,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -441,7 +288,6 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
apps={server.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
||||
appIds={MCP_APP_IDS}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -449,9 +295,8 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 disabled:opacity-100"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onEdit(id)}
|
||||
disabled={disabled}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
@@ -460,9 +305,8 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-red-500 hover:bg-red-100 disabled:opacity-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
className="h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
onClick={() => onDelete(id)}
|
||||
disabled={disabled}
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -11,7 +11,7 @@ interface PromptFormPanelProps {
|
||||
appId: AppId;
|
||||
editingId?: string;
|
||||
initialData?: Prompt;
|
||||
onSave: (id: string, prompt: Prompt) => Promise<void | boolean>;
|
||||
onSave: (id: string, prompt: Prompt) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -32,14 +32,13 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
grokbuild: "AGENTS.md",
|
||||
opencode: "AGENTS.md",
|
||||
openclaw: "AGENTS.md",
|
||||
hermes: "SOUL.md",
|
||||
hermes: "AGENTS.md",
|
||||
};
|
||||
const filename = filenameMap[appId];
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savingRef = useRef(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,11 +65,10 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
}, [initialData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (savingRef.current || !name.trim()) {
|
||||
if (!name.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const id = editingId || `prompt-${Date.now()}`;
|
||||
@@ -84,22 +82,15 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
createdAt: initialData?.createdAt || timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
const saved = await onSave(id, prompt);
|
||||
if (saved !== false) {
|
||||
await onSave(id, prompt);
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
// Error handled by hook
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!savingRef.current) onClose();
|
||||
};
|
||||
|
||||
const title = editingId
|
||||
? t("prompts.editTitle", { appName })
|
||||
: t("prompts.addTitle", { appName });
|
||||
@@ -108,7 +99,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
<FullScreenPanel
|
||||
isOpen={true}
|
||||
title={title}
|
||||
onClose={handleClose}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -129,7 +120,6 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder={t("prompts.namePlaceholder")}
|
||||
className="mt-2"
|
||||
/>
|
||||
@@ -143,7 +133,6 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder={t("prompts.descriptionPlaceholder")}
|
||||
className="mt-2"
|
||||
/>
|
||||
@@ -158,7 +147,6 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
onChange={setContent}
|
||||
placeholder={t("prompts.contentPlaceholder", { filename })}
|
||||
darkMode={isDarkMode}
|
||||
readOnly={saving}
|
||||
minHeight="167px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ interface PromptListItemProps {
|
||||
onToggle: (id: string, enabled: boolean) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const PromptListItem: React.FC<PromptListItemProps> = ({
|
||||
@@ -20,7 +19,6 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -34,7 +32,6 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
|
||||
<PromptToggle
|
||||
enabled={enabled}
|
||||
onChange={(newEnabled) => onToggle(id, newEnabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -53,8 +50,6 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(id)}
|
||||
disabled={disabled}
|
||||
className="disabled:opacity-100"
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
@@ -64,8 +59,7 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(id)}
|
||||
disabled={disabled}
|
||||
className="hover:text-red-500 hover:bg-red-100 disabled:opacity-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { type AppId } from "@/lib/api";
|
||||
import { usePromptActions } from "@/hooks/usePromptActions";
|
||||
import { useTauriEvent } from "@/hooks/useTauriEvent";
|
||||
import { ManagementListSearch } from "@/components/common/ManagementListSearch";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import PromptListItem from "./PromptListItem";
|
||||
import PromptFormPanel from "./PromptFormPanel";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
@@ -14,8 +12,6 @@ interface PromptPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appId: AppId;
|
||||
onInteractionBlockedChange?: (blocked: boolean) => void;
|
||||
onNavigationBlockedChange?: (blocked: boolean) => void;
|
||||
}
|
||||
|
||||
export interface PromptPanelHandle {
|
||||
@@ -23,14 +19,10 @@ export interface PromptPanelHandle {
|
||||
}
|
||||
|
||||
const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
(
|
||||
{ open, appId, onInteractionBlockedChange, onNavigationBlockedChange },
|
||||
ref,
|
||||
) => {
|
||||
({ open, appId }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
titleKey: string;
|
||||
@@ -38,13 +30,6 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
messageParams?: Record<string, unknown>;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [writePending, setWritePending] = useState(false);
|
||||
const [reloadPending, setReloadPending] = useState(false);
|
||||
const writeLockRef = React.useRef(false);
|
||||
const reloadLockRef = React.useRef(false);
|
||||
const reloadRunGenerationRef = React.useRef(0);
|
||||
const overlayOpenRef = React.useRef(false);
|
||||
const externalReloadQueuedRef = React.useRef(false);
|
||||
|
||||
const {
|
||||
prompts,
|
||||
@@ -54,80 +39,10 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
deletePrompt,
|
||||
toggleEnabled,
|
||||
} = usePromptActions(appId);
|
||||
const reloadRef = React.useRef(reload);
|
||||
reloadRef.current = reload;
|
||||
|
||||
const dialogOpen = confirmDialog !== null;
|
||||
const interactionBlocked =
|
||||
loading || reloadPending || writePending || isFormOpen || dialogOpen;
|
||||
const navigationBlocked = writePending || isFormOpen || dialogOpen;
|
||||
|
||||
useEffect(() => {
|
||||
onInteractionBlockedChange?.(interactionBlocked);
|
||||
}, [interactionBlocked, onInteractionBlockedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onNavigationBlockedChange?.(navigationBlocked);
|
||||
}, [navigationBlocked, onNavigationBlockedChange]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
onInteractionBlockedChange?.(false);
|
||||
onNavigationBlockedChange?.(false);
|
||||
},
|
||||
[onInteractionBlockedChange, onNavigationBlockedChange],
|
||||
);
|
||||
|
||||
const runExternalReload = React.useCallback(async () => {
|
||||
if (writeLockRef.current || overlayOpenRef.current) {
|
||||
externalReloadQueuedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const runGeneration = ++reloadRunGenerationRef.current;
|
||||
externalReloadQueuedRef.current = false;
|
||||
reloadLockRef.current = true;
|
||||
setReloadPending(true);
|
||||
try {
|
||||
await reloadRef.current();
|
||||
} finally {
|
||||
if (reloadRunGenerationRef.current === runGeneration) {
|
||||
reloadLockRef.current = false;
|
||||
setReloadPending(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const beginWrite = () => {
|
||||
if (loading || reloadLockRef.current || writeLockRef.current)
|
||||
return false;
|
||||
writeLockRef.current = true;
|
||||
setWritePending(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const endWrite = () => {
|
||||
writeLockRef.current = false;
|
||||
setWritePending(false);
|
||||
if (externalReloadQueuedRef.current) {
|
||||
void runExternalReload();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void runExternalReload();
|
||||
}, [appId, open, runExternalReload]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchQuery("");
|
||||
overlayOpenRef.current = false;
|
||||
setIsFormOpen(false);
|
||||
setEditingId(null);
|
||||
setConfirmDialog(null);
|
||||
if (externalReloadQueuedRef.current) {
|
||||
void runExternalReload();
|
||||
}
|
||||
}, [appId, runExternalReload]);
|
||||
if (open) reload();
|
||||
}, [open, reload]);
|
||||
|
||||
// Listen for prompt import events from deep link
|
||||
useEffect(() => {
|
||||
@@ -135,7 +50,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
const customEvent = event as CustomEvent;
|
||||
// Reload if the import is for this app
|
||||
if (customEvent.detail?.app === appId) {
|
||||
void runExternalReload();
|
||||
reload();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,16 +58,12 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
return () => {
|
||||
window.removeEventListener("prompt-imported", handlePromptImported);
|
||||
};
|
||||
}, [appId, runExternalReload]);
|
||||
}, [appId, reload]);
|
||||
|
||||
// 应用项目 Profile 会切换激活的 prompt(prompts 非 react-query,需主动 reload)
|
||||
useTauriEvent("profile-applied", runExternalReload);
|
||||
useTauriEvent("profile-applied", reload);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (reloadLockRef.current || writeLockRef.current || interactionBlocked) {
|
||||
return;
|
||||
}
|
||||
overlayOpenRef.current = true;
|
||||
setEditingId(null);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
@@ -162,103 +73,29 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
}));
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
if (reloadLockRef.current || writeLockRef.current || interactionBlocked) {
|
||||
return;
|
||||
}
|
||||
overlayOpenRef.current = true;
|
||||
setEditingId(id);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (reloadLockRef.current || writeLockRef.current || interactionBlocked) {
|
||||
return;
|
||||
}
|
||||
const prompt = prompts[id];
|
||||
overlayOpenRef.current = true;
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
titleKey: "prompts.confirm.deleteTitle",
|
||||
messageKey: "prompts.confirm.deleteMessage",
|
||||
messageParams: { name: prompt?.name },
|
||||
onConfirm: async () => {
|
||||
if (!beginWrite()) return;
|
||||
try {
|
||||
const refreshed = await deletePrompt(id);
|
||||
if (refreshed === false) {
|
||||
externalReloadQueuedRef.current = true;
|
||||
}
|
||||
overlayOpenRef.current = false;
|
||||
await deletePrompt(id);
|
||||
setConfirmDialog(null);
|
||||
} catch (e) {
|
||||
// Error handled by hook
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string, enabled: boolean) => {
|
||||
if (!beginWrite()) return;
|
||||
try {
|
||||
const refreshed = await toggleEnabled(id, enabled);
|
||||
if (refreshed === false) {
|
||||
externalReloadQueuedRef.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Error handled by hook
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (
|
||||
id: string,
|
||||
prompt: Parameters<typeof savePrompt>[1],
|
||||
) => {
|
||||
if (!beginWrite()) return false;
|
||||
try {
|
||||
const refreshed = await savePrompt(id, prompt);
|
||||
if (refreshed === false) {
|
||||
externalReloadQueuedRef.current = true;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Error handled by hook
|
||||
return false;
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
if (writeLockRef.current) return;
|
||||
overlayOpenRef.current = false;
|
||||
setIsFormOpen(false);
|
||||
setEditingId(null);
|
||||
if (externalReloadQueuedRef.current) {
|
||||
void runExternalReload();
|
||||
}
|
||||
};
|
||||
|
||||
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
|
||||
const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase();
|
||||
const filteredPromptEntries = useMemo(() => {
|
||||
if (!normalizedSearchQuery) return promptEntries;
|
||||
|
||||
return promptEntries.filter(([recordId, prompt]) =>
|
||||
[
|
||||
recordId,
|
||||
prompt.id,
|
||||
prompt.name,
|
||||
prompt.description,
|
||||
prompt.content,
|
||||
].some((value) =>
|
||||
value?.toLocaleLowerCase().includes(normalizedSearchQuery),
|
||||
),
|
||||
);
|
||||
}, [normalizedSearchQuery, promptEntries]);
|
||||
|
||||
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
||||
|
||||
@@ -273,16 +110,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ManagementListSearch
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={t("prompts.searchPlaceholder")}
|
||||
ariaLabel={t("prompts.searchAriaLabel")}
|
||||
clearLabel={t("common.clear")}
|
||||
/>
|
||||
|
||||
<ScrollArea className="-mr-3 flex-1 min-h-0" type="auto">
|
||||
<div className="pb-16 pr-3">
|
||||
<div className="flex-1 overflow-y-auto pb-16">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t("prompts.loading")}
|
||||
@@ -299,36 +127,29 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
{t("prompts.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredPromptEntries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground">
|
||||
<Search className="mb-4 h-10 w-10 opacity-40" />
|
||||
<p className="text-sm">{t("prompts.noSearchResults")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredPromptEntries.map(([id, prompt]) => (
|
||||
{promptEntries.map(([id, prompt]) => (
|
||||
<PromptListItem
|
||||
key={id}
|
||||
id={id}
|
||||
prompt={prompt}
|
||||
onToggle={handleToggle}
|
||||
onToggle={toggleEnabled}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
disabled={interactionBlocked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{isFormOpen && (
|
||||
<PromptFormPanel
|
||||
appId={appId}
|
||||
editingId={editingId || undefined}
|
||||
initialData={editingId ? prompts[editingId] : undefined}
|
||||
onSave={handleSave}
|
||||
onClose={handleCloseForm}
|
||||
onSave={savePrompt}
|
||||
onClose={() => setIsFormOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -337,17 +158,8 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={t(confirmDialog.titleKey)}
|
||||
message={t(confirmDialog.messageKey, confirmDialog.messageParams)}
|
||||
pending={writePending}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => {
|
||||
if (!writeLockRef.current) {
|
||||
overlayOpenRef.current = false;
|
||||
setConfirmDialog(null);
|
||||
if (externalReloadQueuedRef.current) {
|
||||
void runExternalReload();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCodexOauth } from "./hooks/useCodexOauth";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota";
|
||||
|
||||
interface CodexOAuthSectionProps {
|
||||
className?: string;
|
||||
/** 是否展示每个账号的订阅额度 */
|
||||
showAccountQuota?: boolean;
|
||||
/** 当前选中的 ChatGPT 账号 ID */
|
||||
selectedAccountId?: string | null;
|
||||
/** 账号选择回调 */
|
||||
@@ -48,7 +45,6 @@ interface CodexOAuthSectionProps {
|
||||
*/
|
||||
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
className,
|
||||
showAccountQuota = false,
|
||||
selectedAccountId,
|
||||
onAccountSelect,
|
||||
fastModeEnabled = false,
|
||||
@@ -182,9 +178,8 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="space-y-2 p-2 rounded-md border bg-muted/30"
|
||||
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{account.login}</span>
|
||||
@@ -225,10 +220,6 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showAccountQuota && (
|
||||
<CodexOauthAccountQuota accountId={account.id} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { providersApi } from "@/lib/api";
|
||||
import { getOpenCodeModels } from "@/lib/api/model-fetch";
|
||||
import { useProvidersQuery } from "@/lib/query/queries";
|
||||
import type { OpenCodeProviderConfig } from "@/types";
|
||||
import { OPENCODE_PRESET_MODEL_VARIANTS } from "@/config/opencodeProviderPresets";
|
||||
import { parseOpencodeConfigStrict } from "../helpers/opencodeFormUtils";
|
||||
|
||||
const EMPTY_DISCOVERED_MODELS: Awaited<ReturnType<typeof getOpenCodeModels>> =
|
||||
[];
|
||||
|
||||
interface UseOmoModelSourceParams {
|
||||
isOmoCategory: boolean;
|
||||
providerId?: string;
|
||||
@@ -50,19 +45,6 @@ export function useOmoModelSource({
|
||||
}: UseOmoModelSourceParams): OmoModelSourceResult {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data: discoveredModels = EMPTY_DISCOVERED_MODELS,
|
||||
isError: runtimeModelsFailed,
|
||||
error: runtimeModelsError,
|
||||
} = useQuery({
|
||||
queryKey: ["opencode", "runtime-models"],
|
||||
queryFn: getOpenCodeModels,
|
||||
enabled: isOmoCategory,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
|
||||
const existingOpencodeKeys = useMemo(() => {
|
||||
if (!opencodeProvidersData?.providers) return [];
|
||||
@@ -76,7 +58,6 @@ export function useOmoModelSource({
|
||||
>(null);
|
||||
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
|
||||
const lastOmoModelSourceWarningRef = useRef<string>("");
|
||||
const lastRuntimeModelsWarningRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -126,6 +107,20 @@ export function useOmoModelSource({
|
||||
return empty;
|
||||
}
|
||||
|
||||
const allProviders = opencodeProvidersData?.providers;
|
||||
if (!allProviders) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const shouldFilterByLive = !omoLiveIdsLoadFailed;
|
||||
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
|
||||
return empty;
|
||||
}
|
||||
const liveSet =
|
||||
shouldFilterByLive && enabledOpencodeProviderIds
|
||||
? new Set(enabledOpencodeProviderIds)
|
||||
: null;
|
||||
|
||||
const dedupedOptions = new Map<string, string>();
|
||||
const variantsMap: Record<string, string[]> = {};
|
||||
const presetMetaMap: Record<
|
||||
@@ -137,19 +132,6 @@ export function useOmoModelSource({
|
||||
> = {};
|
||||
const parseFailedProviders: string[] = [];
|
||||
|
||||
const allProviders = opencodeProvidersData?.providers;
|
||||
const liveReady =
|
||||
omoLiveIdsLoadFailed || enabledOpencodeProviderIds !== null;
|
||||
|
||||
// Configured providers are filtered by live ids when available.
|
||||
// Runtime models are merged regardless, so OAuth/Zen entries still show
|
||||
// while live/provider queries are in flight.
|
||||
if (allProviders && liveReady) {
|
||||
const liveSet =
|
||||
!omoLiveIdsLoadFailed && enabledOpencodeProviderIds
|
||||
? new Set(enabledOpencodeProviderIds)
|
||||
: null;
|
||||
|
||||
for (const [providerKey, provider] of Object.entries(allProviders)) {
|
||||
if (provider.category === "omo" || provider.category === "omo-slim") {
|
||||
continue;
|
||||
@@ -234,14 +216,6 @@ export function useOmoModelSource({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const model of discoveredModels) {
|
||||
const value = `${model.providerId}/${model.modelId}`;
|
||||
if (!dedupedOptions.has(value)) {
|
||||
dedupedOptions.set(value, `${model.providerId} / ${model.modelId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options: Array.from(dedupedOptions.entries())
|
||||
@@ -257,7 +231,6 @@ export function useOmoModelSource({
|
||||
opencodeProvidersData?.providers,
|
||||
enabledOpencodeProviderIds,
|
||||
omoLiveIdsLoadFailed,
|
||||
discoveredModels,
|
||||
]);
|
||||
|
||||
// Warning toast for parse failures / fallback
|
||||
@@ -298,32 +271,6 @@ export function useOmoModelSource({
|
||||
t,
|
||||
]);
|
||||
|
||||
// Warning toast when OpenCode runtime model discovery fails
|
||||
useEffect(() => {
|
||||
if (!isOmoCategory || !runtimeModelsFailed) {
|
||||
if (!isOmoCategory) {
|
||||
lastRuntimeModelsWarningRef.current = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = String(
|
||||
(runtimeModelsError as { message?: string } | null)?.message ||
|
||||
runtimeModelsError ||
|
||||
"",
|
||||
);
|
||||
const signature = detail || "runtime-models-failed";
|
||||
if (lastRuntimeModelsWarningRef.current === signature) return;
|
||||
lastRuntimeModelsWarningRef.current = signature;
|
||||
|
||||
toast.warning(
|
||||
t("omo.runtimeModelsFailedWarning", {
|
||||
defaultValue:
|
||||
"Failed to load OpenCode runtime models. Showing configured providers only.",
|
||||
}),
|
||||
);
|
||||
}, [isOmoCategory, runtimeModelsFailed, runtimeModelsError, t]);
|
||||
|
||||
return {
|
||||
omoModelOptions: omoModelBuild.options,
|
||||
omoModelVariantsMap: omoModelBuild.variantsMap,
|
||||
|
||||
@@ -84,7 +84,7 @@ export function ClaudeDesktopRouteToggle({
|
||||
className={cn(
|
||||
"h-4 w-4 transition-colors",
|
||||
isRunning
|
||||
? "text-emerald-500 status-heartbeat"
|
||||
? "text-emerald-500 animate-pulse"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
className={cn(
|
||||
"h-4 w-4 transition-colors",
|
||||
isEnabled
|
||||
? "text-emerald-500 status-heartbeat"
|
||||
? "text-emerald-500 animate-pulse"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
||||
className={cn(
|
||||
"h-4 w-4 transition-colors",
|
||||
takeoverEnabled
|
||||
? "text-emerald-500 status-heartbeat"
|
||||
? "text-emerald-500 animate-pulse"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function AuthCenterPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodexOAuthSection showAccountQuota />
|
||||
<CodexOAuthSection />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
|
||||
|
||||
@@ -118,7 +118,7 @@ export function ProxyTabContent({
|
||||
className="gap-1.5 h-6 ml-auto mr-2"
|
||||
>
|
||||
<Activity
|
||||
className={`h-3 w-3 ${isRunning ? "status-heartbeat" : ""}`}
|
||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{isRunning
|
||||
? t("settings.advanced.proxy.running")
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
useInstalledSkills,
|
||||
useSkillBackups,
|
||||
useRestoreSkillBackup,
|
||||
useBulkToggleSkillApp,
|
||||
useToggleSkillApp,
|
||||
useUninstallSkill,
|
||||
useScanUnmanagedSkills,
|
||||
@@ -30,7 +28,6 @@ import {
|
||||
type SkillUpdateInfo,
|
||||
} from "@/hooks/useSkills";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { settingsApi, skillsApi } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
@@ -38,8 +35,6 @@ import { SKILLS_APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
import { ManagementListSearch } from "@/components/common/ManagementListSearch";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -52,14 +47,6 @@ import {
|
||||
interface UnifiedSkillsPanelProps {
|
||||
onOpenDiscovery: () => void;
|
||||
currentApp: AppId;
|
||||
onInteractionBlockedChange?: (blocked: boolean) => void;
|
||||
onNavigationBlockedChange?: (blocked: boolean) => void;
|
||||
onCheckUpdatesStateChange?: (state: SkillsCheckUpdatesState) => void;
|
||||
}
|
||||
|
||||
export interface SkillsCheckUpdatesState {
|
||||
isChecking: boolean;
|
||||
hasSkills: boolean;
|
||||
}
|
||||
|
||||
export interface UnifiedSkillsPanelHandle {
|
||||
@@ -80,14 +67,7 @@ function formatSkillBackupDate(unixSeconds: number): string {
|
||||
const UnifiedSkillsPanel = React.forwardRef<
|
||||
UnifiedSkillsPanelHandle,
|
||||
UnifiedSkillsPanelProps
|
||||
>((props, ref) => {
|
||||
const {
|
||||
onOpenDiscovery,
|
||||
currentApp,
|
||||
onInteractionBlockedChange,
|
||||
onNavigationBlockedChange,
|
||||
onCheckUpdatesStateChange,
|
||||
} = props;
|
||||
>(({ onOpenDiscovery, currentApp }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
@@ -99,10 +79,6 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
} | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [writePending, setWritePending] = useState(false);
|
||||
const writeLockRef = React.useRef(false);
|
||||
const checkUpdatesLockRef = React.useRef(false);
|
||||
|
||||
const { data: skills, isLoading } = useInstalledSkills();
|
||||
const {
|
||||
@@ -112,7 +88,6 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
} = useSkillBackups();
|
||||
const deleteBackupMutation = useDeleteSkillBackup();
|
||||
const toggleAppMutation = useToggleSkillApp();
|
||||
const bulkToggleAppMutation = useBulkToggleSkillApp();
|
||||
const uninstallMutation = useUninstallSkill();
|
||||
const restoreBackupMutation = useRestoreSkillBackup();
|
||||
// enabled: true —— 进入 Skill 页面时自动静默扫描一次(绿点提示来源)
|
||||
@@ -128,84 +103,15 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const updateSkillMutation = useUpdateSkill();
|
||||
const [isUpdatingAll, setIsUpdatingAll] = useState(false);
|
||||
|
||||
const mutationPending =
|
||||
deleteBackupMutation.isPending ||
|
||||
toggleAppMutation.isPending ||
|
||||
bulkToggleAppMutation.isPending ||
|
||||
uninstallMutation.isPending ||
|
||||
restoreBackupMutation.isPending ||
|
||||
importMutation.isPending ||
|
||||
installFromZipMutation.isPending ||
|
||||
updateSkillMutation.isPending ||
|
||||
isUpdatingAll;
|
||||
const dialogOpen =
|
||||
importDialogOpen || restoreDialogOpen || confirmDialog !== null;
|
||||
const navigationBlocked = writePending || mutationPending || dialogOpen;
|
||||
const interactionBlocked = navigationBlocked || isCheckingUpdates;
|
||||
|
||||
React.useEffect(() => {
|
||||
onInteractionBlockedChange?.(interactionBlocked);
|
||||
}, [interactionBlocked, onInteractionBlockedChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onNavigationBlockedChange?.(navigationBlocked);
|
||||
}, [navigationBlocked, onNavigationBlockedChange]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
onInteractionBlockedChange?.(false);
|
||||
onNavigationBlockedChange?.(false);
|
||||
},
|
||||
[onInteractionBlockedChange, onNavigationBlockedChange],
|
||||
);
|
||||
|
||||
const hasSkills = (skills?.length ?? 0) > 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
onCheckUpdatesStateChange?.({
|
||||
isChecking: isCheckingUpdates,
|
||||
hasSkills,
|
||||
});
|
||||
}, [hasSkills, isCheckingUpdates, onCheckUpdatesStateChange]);
|
||||
|
||||
React.useEffect(
|
||||
() => () =>
|
||||
onCheckUpdatesStateChange?.({ isChecking: false, hasSkills: false }),
|
||||
[onCheckUpdatesStateChange],
|
||||
);
|
||||
|
||||
const beginWrite = (allowOpenDialog = false) => {
|
||||
if (
|
||||
checkUpdatesLockRef.current ||
|
||||
isCheckingUpdates ||
|
||||
writeLockRef.current ||
|
||||
mutationPending ||
|
||||
(!allowOpenDialog && dialogOpen)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
writeLockRef.current = true;
|
||||
setWritePending(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const endWrite = () => {
|
||||
writeLockRef.current = false;
|
||||
setWritePending(false);
|
||||
};
|
||||
|
||||
const applicableSkillUpdates = useMemo(() => {
|
||||
const installedIds = new Set((skills ?? []).map((skill) => skill.id));
|
||||
return (skillUpdates ?? []).filter((update) => installedIds.has(update.id));
|
||||
}, [skillUpdates, skills]);
|
||||
|
||||
const updatesMap = useMemo(() => {
|
||||
const map: Record<string, SkillUpdateInfo> = {};
|
||||
for (const update of applicableSkillUpdates) {
|
||||
map[update.id] = update;
|
||||
if (skillUpdates) {
|
||||
for (const u of skillUpdates) {
|
||||
map[u.id] = u;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [applicableSkillUpdates]);
|
||||
}, [skillUpdates]);
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = {
|
||||
@@ -227,97 +133,31 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
return counts;
|
||||
}, [skills]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!skills) return [];
|
||||
|
||||
const query = searchQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return skills;
|
||||
|
||||
return skills.filter((skill) => {
|
||||
const searchableValues = [
|
||||
skill.name,
|
||||
skill.id,
|
||||
skill.description,
|
||||
skill.directory,
|
||||
skill.repoOwner,
|
||||
skill.repoName,
|
||||
skill.repoOwner && skill.repoName
|
||||
? `${skill.repoOwner}/${skill.repoName}`
|
||||
: undefined,
|
||||
];
|
||||
|
||||
return searchableValues.some((value) =>
|
||||
value?.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
}, [searchQuery, skills]);
|
||||
|
||||
const pendingApp = bulkToggleAppMutation.isPending
|
||||
? bulkToggleAppMutation.variables?.app
|
||||
: toggleAppMutation.isPending
|
||||
? toggleAppMutation.variables?.app
|
||||
: null;
|
||||
|
||||
const handleToggleApp = async (id: string, app: AppId, enabled: boolean) => {
|
||||
if (!beginWrite()) return;
|
||||
|
||||
try {
|
||||
await toggleAppMutation.mutateAsync({ id, app, enabled });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAll = async (app: AppId, enabled: boolean) => {
|
||||
if (!skills || !beginWrite()) return;
|
||||
|
||||
const ids = skills
|
||||
.filter((skill) => Boolean(skill.apps[app]) !== enabled)
|
||||
.map((skill) => skill.id);
|
||||
if (ids.length === 0) {
|
||||
endWrite();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bulkToggleAppMutation.mutateAsync({
|
||||
ids,
|
||||
app,
|
||||
enabled,
|
||||
});
|
||||
if (result.failed.length > 0) {
|
||||
toast.error(
|
||||
t("common.bulkToggleFailed", { count: result.failed.length }),
|
||||
{ description: String(result.failed[0].error) },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.bulkToggleFailed", { count: ids.length }), {
|
||||
description: String(error),
|
||||
});
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = (skill: InstalledSkill) => {
|
||||
if (
|
||||
checkUpdatesLockRef.current ||
|
||||
writeLockRef.current ||
|
||||
interactionBlocked
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: t("skills.uninstall"),
|
||||
message: t("skills.uninstallConfirm", { name: skill.name }),
|
||||
onConfirm: async () => {
|
||||
if (!beginWrite(true)) return;
|
||||
try {
|
||||
const result = await uninstallMutation.mutateAsync(skill.id);
|
||||
// 构建 skillKey 用于更新 discoverable 缓存
|
||||
const installName =
|
||||
skill.directory.split(/[/\\]/).pop()?.toLowerCase() ||
|
||||
skill.directory.toLowerCase();
|
||||
const skillKey = `${installName}:${skill.repoOwner?.toLowerCase() || ""}:${skill.repoName?.toLowerCase() || ""}`;
|
||||
|
||||
const result = await uninstallMutation.mutateAsync({
|
||||
id: skill.id,
|
||||
skillKey,
|
||||
});
|
||||
setConfirmDialog(null);
|
||||
toast.success(t("skills.uninstallSuccess", { name: skill.name }), {
|
||||
description: result.backupPath
|
||||
@@ -327,15 +167,12 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenImport = async () => {
|
||||
if (!beginWrite()) return;
|
||||
try {
|
||||
const result = await scanUnmanaged();
|
||||
if (!result.data || result.data.length === 0) {
|
||||
@@ -345,13 +182,10 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
setImportDialogOpen(true);
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (imports: ImportSkillSelection[]) => {
|
||||
if (!beginWrite(true)) return;
|
||||
try {
|
||||
const imported = await importMutation.mutateAsync(imports);
|
||||
setImportDialogOpen(false);
|
||||
@@ -360,13 +194,10 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallFromZip = async () => {
|
||||
if (!beginWrite()) return;
|
||||
try {
|
||||
const filePath = await skillsApi.openZipFileDialog();
|
||||
if (!filePath) return;
|
||||
@@ -382,9 +213,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
});
|
||||
} else if (installed.length === 1) {
|
||||
toast.success(
|
||||
t("skills.installFromZip.successSingle", {
|
||||
name: installed[0].name,
|
||||
}),
|
||||
t("skills.installFromZip.successSingle", { name: installed[0].name }),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} else {
|
||||
@@ -397,20 +226,10 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("skills.installFailed"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
if (
|
||||
checkUpdatesLockRef.current ||
|
||||
writeLockRef.current ||
|
||||
interactionBlocked
|
||||
) {
|
||||
return;
|
||||
}
|
||||
checkUpdatesLockRef.current = true;
|
||||
try {
|
||||
const result = await checkUpdates();
|
||||
const updates = result.data || [];
|
||||
@@ -423,13 +242,10 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
} finally {
|
||||
checkUpdatesLockRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSkill = async (skill: InstalledSkill) => {
|
||||
if (!beginWrite()) return;
|
||||
try {
|
||||
const updated = await updateSkillMutation.mutateAsync(skill.id);
|
||||
toast.success(t("skills.updateSuccess", { name: updated.name }), {
|
||||
@@ -437,19 +253,14 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("skills.updateFailed"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateAll = async () => {
|
||||
if (applicableSkillUpdates.length === 0 || !beginWrite()) {
|
||||
return;
|
||||
}
|
||||
if (!skillUpdates || skillUpdates.length === 0) return;
|
||||
setIsUpdatingAll(true);
|
||||
let successCount = 0;
|
||||
try {
|
||||
for (const update of applicableSkillUpdates) {
|
||||
for (const update of skillUpdates) {
|
||||
try {
|
||||
await updateSkillMutation.mutateAsync(update.id);
|
||||
successCount++;
|
||||
@@ -459,10 +270,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsUpdatingAll(false);
|
||||
endWrite();
|
||||
}
|
||||
if (successCount > 0) {
|
||||
toast.success(t("skills.updateAllSuccess", { count: successCount }), {
|
||||
closeButton: true,
|
||||
@@ -471,20 +279,15 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
};
|
||||
|
||||
const handleOpenRestoreFromBackup = async () => {
|
||||
if (!beginWrite()) return;
|
||||
setRestoreDialogOpen(true);
|
||||
try {
|
||||
await refetchSkillBackups({ throwOnError: true });
|
||||
await refetchSkillBackups();
|
||||
} catch (error) {
|
||||
setRestoreDialogOpen(false);
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreFromBackup = async (backupId: string) => {
|
||||
if (!beginWrite(true)) return;
|
||||
try {
|
||||
const restored = await restoreBackupMutation.mutateAsync({
|
||||
backupId,
|
||||
@@ -501,13 +304,10 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
toast.error(t("skills.restoreFromBackup.failed"), {
|
||||
description: String(error),
|
||||
});
|
||||
} finally {
|
||||
endWrite();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBackup = (backup: SkillBackupEntry) => {
|
||||
if (checkUpdatesLockRef.current || writeLockRef.current) return;
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: t("skills.restoreFromBackup.deleteConfirmTitle"),
|
||||
@@ -517,49 +317,9 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
confirmText: t("skills.restoreFromBackup.delete"),
|
||||
variant: "destructive",
|
||||
onConfirm: async () => {
|
||||
if (!beginWrite(true)) return;
|
||||
try {
|
||||
let deleteSucceeded = false;
|
||||
let deleteError: unknown;
|
||||
try {
|
||||
await deleteBackupMutation.mutateAsync(backup.backupId);
|
||||
deleteSucceeded = true;
|
||||
} catch (error) {
|
||||
deleteError = error;
|
||||
}
|
||||
|
||||
// The backups query is disabled by default, so invalidation alone
|
||||
// does not fetch authoritative data. Explicitly refresh after both
|
||||
// success and failure (remove_dir_all may have made partial progress).
|
||||
let refreshedBackups: SkillBackupEntry[] | undefined;
|
||||
try {
|
||||
const result = await refetchSkillBackups({ throwOnError: true });
|
||||
refreshedBackups = result.data;
|
||||
} catch (error) {
|
||||
// A refresh failure must not turn a completed deletion into a false
|
||||
// "delete failed" report, or replace the original deletion error.
|
||||
console.error(
|
||||
"Failed to refresh Skill backups after deletion:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (!deleteSucceeded) {
|
||||
// remove_dir_all may finish removing the directory but still
|
||||
// report an error. If the authoritative refresh confirms that the
|
||||
// item is gone, close the now-stale confirmation dialog.
|
||||
if (
|
||||
refreshedBackups &&
|
||||
!refreshedBackups.some(
|
||||
(entry) => entry.backupId === backup.backupId,
|
||||
)
|
||||
) {
|
||||
setConfirmDialog(null);
|
||||
}
|
||||
toast.error(t("skills.restoreFromBackup.deleteFailed"), {
|
||||
description: String(deleteError),
|
||||
});
|
||||
} else {
|
||||
await refetchSkillBackups();
|
||||
setConfirmDialog(null);
|
||||
toast.success(
|
||||
t("skills.restoreFromBackup.deleteSuccess", {
|
||||
@@ -569,24 +329,17 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
endWrite();
|
||||
} catch (error) {
|
||||
toast.error(t("skills.restoreFromBackup.deleteFailed"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openDiscovery: () => {
|
||||
if (
|
||||
!checkUpdatesLockRef.current &&
|
||||
!writeLockRef.current &&
|
||||
!interactionBlocked
|
||||
) {
|
||||
onOpenDiscovery();
|
||||
}
|
||||
},
|
||||
openDiscovery: onOpenDiscovery,
|
||||
openImport: handleOpenImport,
|
||||
openInstallFromZip: handleInstallFromZip,
|
||||
openRestoreFromBackup: handleOpenRestoreFromBackup,
|
||||
@@ -595,32 +348,28 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<AppCountBar
|
||||
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
||||
counts={enabledCounts}
|
||||
appIds={SKILLS_APP_IDS}
|
||||
totalCount={skills?.length ?? 0}
|
||||
onToggleAll={handleToggleAll}
|
||||
pendingApp={pendingApp}
|
||||
disabled={interactionBlocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="mb-4 overflow-hidden transition-all duration-300 ease-out"
|
||||
className="transition-all duration-300 ease-out overflow-hidden"
|
||||
style={{
|
||||
maxWidth: applicableSkillUpdates.length > 0 ? "200px" : "0px",
|
||||
opacity: applicableSkillUpdates.length > 0 ? 1 : 0,
|
||||
maxWidth:
|
||||
skillUpdates && skillUpdates.length > 0 ? "200px" : "0px",
|
||||
opacity: skillUpdates && skillUpdates.length > 0 ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1 whitespace-nowrap disabled:opacity-100"
|
||||
className="h-7 text-xs gap-1 whitespace-nowrap"
|
||||
onClick={handleUpdateAll}
|
||||
disabled={interactionBlocked}
|
||||
disabled={isUpdatingAll || updateSkillMutation.isPending}
|
||||
>
|
||||
{isUpdatingAll ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
@@ -629,23 +378,30 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
)}
|
||||
{isUpdatingAll
|
||||
? t("skills.updatingAll")
|
||||
: t("skills.updateAll", {
|
||||
count: applicableSkillUpdates.length,
|
||||
})}
|
||||
: t("skills.updateAll", { count: skillUpdates?.length ?? 0 })}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={handleCheckUpdates}
|
||||
disabled={isCheckingUpdates || !skills || skills.length === 0}
|
||||
>
|
||||
{isCheckingUpdates ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
{isCheckingUpdates
|
||||
? t("skills.checkingUpdates")
|
||||
: t("skills.checkUpdates")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ManagementListSearch
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={t("skills.installedSearchPlaceholder")}
|
||||
ariaLabel={t("skills.installedSearchAriaLabel")}
|
||||
clearLabel={t("common.clear")}
|
||||
/>
|
||||
|
||||
<ScrollArea className="-mr-3 flex-1 min-h-0" type="auto">
|
||||
<div className="pb-24 pr-3">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t("skills.loading")}
|
||||
@@ -662,15 +418,10 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
{t("skills.noInstalledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground">
|
||||
<Search className="mb-4 h-10 w-10 opacity-40" />
|
||||
<p className="text-sm">{t("skills.noInstalledSearchResults")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="rounded-xl border border-border-default overflow-hidden">
|
||||
{filteredSkills.map((skill, index) => (
|
||||
{skills.map((skill, index) => (
|
||||
<InstalledSkillListItem
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
@@ -679,18 +430,16 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
updateSkillMutation.isPending &&
|
||||
updateSkillMutation.variables === skill.id
|
||||
}
|
||||
actionsDisabled={interactionBlocked}
|
||||
onToggleApp={handleToggleApp}
|
||||
onUninstall={() => handleUninstall(skill)}
|
||||
onUpdate={() => handleUpdateSkill(skill)}
|
||||
isLast={index === filteredSkills.length - 1}
|
||||
isLast={index === skills.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
@@ -700,7 +449,6 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
confirmText={confirmDialog.confirmText}
|
||||
variant={confirmDialog.variant}
|
||||
zIndex="top"
|
||||
pending={writePending}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
@@ -735,7 +483,6 @@ interface InstalledSkillListItemProps {
|
||||
skill: InstalledSkill;
|
||||
hasUpdate?: boolean;
|
||||
isUpdating?: boolean;
|
||||
actionsDisabled?: boolean;
|
||||
onToggleApp: (id: string, app: AppId, enabled: boolean) => void;
|
||||
onUninstall: () => void;
|
||||
onUpdate?: () => void;
|
||||
@@ -746,7 +493,6 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
skill,
|
||||
hasUpdate,
|
||||
isUpdating,
|
||||
actionsDisabled,
|
||||
onToggleApp,
|
||||
onUninstall,
|
||||
onUpdate,
|
||||
@@ -812,7 +558,6 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
apps={skill.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
|
||||
appIds={SKILLS_APP_IDS}
|
||||
disabled={actionsDisabled}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -824,12 +569,9 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 hover:text-blue-500 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-500/10",
|
||||
actionsDisabled && !isUpdating && "disabled:opacity-100",
|
||||
)}
|
||||
className="h-7 w-7 hover:text-blue-500 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-500/10"
|
||||
onClick={onUpdate}
|
||||
disabled={actionsDisabled || isUpdating}
|
||||
disabled={isUpdating}
|
||||
title={t("skills.update")}
|
||||
>
|
||||
{isUpdating ? (
|
||||
@@ -843,9 +585,8 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-red-500 hover:bg-red-100 disabled:opacity-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
className="h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
onClick={onUninstall}
|
||||
disabled={actionsDisabled}
|
||||
title={t("skills.uninstall")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -890,13 +631,9 @@ const RestoreSkillsDialog: React.FC<RestoreSkillsDialogProps> = ({
|
||||
open,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const actionPending = isRestoring || isDeleting;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => !nextOpen && !actionPending && onClose()}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="max-w-2xl max-h-[85vh] flex flex-col"
|
||||
zIndex="alert"
|
||||
@@ -981,12 +718,7 @@ const RestoreSkillsDialog: React.FC<RestoreSkillsDialogProps> = ({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={actionPending}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -167,7 +167,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.7-code",
|
||||
),
|
||||
partnerPromotionKey: "kimi",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
},
|
||||
@@ -508,6 +507,19 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
icon: "siliconflow",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "NekoCode",
|
||||
websiteUrl: "https://nekocode.ai",
|
||||
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://nekocode.ai",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
|
||||
@@ -106,7 +106,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
partnerPromotionKey: "kimi",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
},
|
||||
@@ -508,6 +507,21 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "siliconflow",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "NekoCode",
|
||||
websiteUrl: "https://nekocode.ai",
|
||||
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://nekocode.ai",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
|
||||
@@ -159,7 +159,6 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
partnerPromotionKey: "kimi",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
},
|
||||
@@ -602,6 +601,22 @@ requires_openai_auth = true`,
|
||||
icon: "siliconflow",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "NekoCode",
|
||||
websiteUrl: "https://nekocode.ai",
|
||||
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"nekocode",
|
||||
"https://nekocode.ai/v1",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://nekocode.ai/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
|
||||
@@ -266,6 +266,18 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
|
||||
partnerPromotionKey: "claudecn",
|
||||
icon: "claudecn",
|
||||
},
|
||||
{
|
||||
name: "NekoCode",
|
||||
websiteUrl: "https://nekocode.ai",
|
||||
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
|
||||
auth: grokAuth(),
|
||||
config: grokPresetConfig("NekoCode", "https://nekocode.ai/v1"),
|
||||
endpointCandidates: ["https://nekocode.ai/v1"],
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
|
||||
@@ -145,7 +145,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
partnerPromotionKey: "kimi",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
suggestedDefaults: {
|
||||
@@ -648,6 +647,25 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NekoCode",
|
||||
websiteUrl: "https://nekocode.ai",
|
||||
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
|
||||
settingsConfig: {
|
||||
name: "nekocode",
|
||||
base_url: "https://nekocode.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.6-sol", provider: "nekocode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
|
||||
@@ -125,7 +125,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
partnerPromotionKey: "kimi",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
templateValues: {
|
||||
@@ -958,6 +957,42 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NekoCode",
|
||||
websiteUrl: "https://nekocode.ai",
|
||||
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://nekocode.ai/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "nekocode/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"nekocode/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
|
||||
@@ -306,7 +306,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
partnerPromotionKey: "kimi",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
templateValues: {
|
||||
@@ -866,6 +865,34 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NekoCode",
|
||||
websiteUrl: "https://nekocode.ai",
|
||||
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "NekoCode",
|
||||
options: {
|
||||
baseURL: "https://nekocode.ai/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
|
||||
+12
-36
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { mcpApi } from "@/lib/api/mcp";
|
||||
import type { McpServer } from "@/types";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { runSequentialBulkAction } from "@/lib/utils/sequentialBulkAction";
|
||||
|
||||
/**
|
||||
* 查询所有 MCP 服务器(统一管理)
|
||||
@@ -21,31 +20,9 @@ export function useUpsertMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
|
||||
// The database is updated before live configs are synchronized, so an
|
||||
// error can still leave a persisted change that the list must reflect.
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle multiple MCP servers serially to avoid lost whole-file writes. */
|
||||
export function useBulkToggleMcpApp() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
serverIds,
|
||||
app,
|
||||
enabled,
|
||||
}: {
|
||||
serverIds: string[];
|
||||
app: AppId;
|
||||
enabled: boolean;
|
||||
}) =>
|
||||
runSequentialBulkAction(serverIds, (serverId) =>
|
||||
mcpApi.toggleApp(serverId, app, enabled),
|
||||
),
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,10 +41,9 @@ export function useToggleMcpApp() {
|
||||
app: AppId;
|
||||
enabled: boolean;
|
||||
}) => mcpApi.toggleApp(serverId, app, enabled),
|
||||
// The backend may update the database before a live-config write fails.
|
||||
// Always refresh so the UI reflects the persisted state after an error.
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,10 +54,9 @@ export function useDeleteMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
|
||||
// Deletion reaches the database before live-config cleanup, so refresh
|
||||
// after both success and failure to avoid operating on a removed entry.
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,7 +69,8 @@ export function useImportMcpFromApps() {
|
||||
mutationFn: () => mcpApi.importFromApps(),
|
||||
// 后端是 best-effort 导入:部分应用失败会返回错误,但其余应用的
|
||||
// 服务器已经入库,失败时也要刷新列表。
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+25
-112
@@ -1,181 +1,101 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { promptsApi, type Prompt, type AppId } from "@/lib/api";
|
||||
|
||||
const EMPTY_PROMPTS: Record<string, Prompt> = {};
|
||||
|
||||
export function usePromptActions(appId: AppId) {
|
||||
const { t } = useTranslation();
|
||||
const [prompts, setPrompts] = useState<Record<string, Prompt>>({});
|
||||
const [promptsAppId, setPromptsAppId] = useState<AppId | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentFileContent, setCurrentFileContent] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [currentFileAppId, setCurrentFileAppId] = useState<AppId | null>(null);
|
||||
const reloadGenerationRef = useRef(0);
|
||||
const currentAppIdRef = useRef(appId);
|
||||
const promptsAppIdRef = useRef<AppId | null>(null);
|
||||
currentAppIdRef.current = appId;
|
||||
|
||||
const visiblePrompts = promptsAppId === appId ? prompts : EMPTY_PROMPTS;
|
||||
const visibleCurrentFileContent =
|
||||
currentFileAppId === appId ? currentFileContent : null;
|
||||
|
||||
const updatePromptsForApp = useCallback(
|
||||
(
|
||||
targetAppId: AppId,
|
||||
updater: (current: Record<string, Prompt>) => Record<string, Prompt>,
|
||||
) => {
|
||||
if (currentAppIdRef.current !== targetAppId) return;
|
||||
|
||||
const previousAppId = promptsAppIdRef.current;
|
||||
setPrompts((current) =>
|
||||
updater(previousAppId === targetAppId ? current : EMPTY_PROMPTS),
|
||||
);
|
||||
promptsAppIdRef.current = targetAppId;
|
||||
setPromptsAppId(targetAppId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
reloadGenerationRef.current += 1;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reload = useCallback(async (): Promise<boolean> => {
|
||||
const requestAppId = appId;
|
||||
if (currentAppIdRef.current !== requestAppId) return false;
|
||||
|
||||
const requestGeneration = ++reloadGenerationRef.current;
|
||||
const isCurrentRequest = () =>
|
||||
reloadGenerationRef.current === requestGeneration &&
|
||||
currentAppIdRef.current === requestAppId;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await promptsApi.getPrompts(requestAppId);
|
||||
if (!isCurrentRequest()) return false;
|
||||
updatePromptsForApp(requestAppId, () => data);
|
||||
const data = await promptsApi.getPrompts(appId);
|
||||
setPrompts(data);
|
||||
|
||||
// 同时加载当前文件内容
|
||||
try {
|
||||
const content = await promptsApi.getCurrentFileContent(requestAppId);
|
||||
if (!isCurrentRequest()) return false;
|
||||
const content = await promptsApi.getCurrentFileContent(appId);
|
||||
setCurrentFileContent(content);
|
||||
setCurrentFileAppId(requestAppId);
|
||||
} catch (error) {
|
||||
if (isCurrentRequest()) {
|
||||
setCurrentFileContent(null);
|
||||
setCurrentFileAppId(requestAppId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCurrentRequest()) {
|
||||
toast.error(t("prompts.loadFailed"));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (isCurrentRequest()) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [appId, t, updatePromptsForApp]);
|
||||
}, [appId, t]);
|
||||
|
||||
const savePrompt = useCallback(
|
||||
async (id: string, prompt: Prompt) => {
|
||||
try {
|
||||
await promptsApi.upsertPrompt(appId, id, prompt);
|
||||
updatePromptsForApp(appId, (current) => ({
|
||||
...current,
|
||||
[id]: prompt,
|
||||
}));
|
||||
const refreshed =
|
||||
currentAppIdRef.current === appId ? await reload() : false;
|
||||
await reload();
|
||||
toast.success(t("prompts.saveSuccess"), { closeButton: true });
|
||||
return refreshed;
|
||||
} catch (error) {
|
||||
toast.error(t("prompts.saveFailed"));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[appId, reload, t, updatePromptsForApp],
|
||||
[appId, reload, t],
|
||||
);
|
||||
|
||||
const deletePrompt = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await promptsApi.deletePrompt(appId, id);
|
||||
updatePromptsForApp(appId, (current) => {
|
||||
const next = { ...current };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
const refreshed =
|
||||
currentAppIdRef.current === appId ? await reload() : false;
|
||||
await reload();
|
||||
toast.success(t("prompts.deleteSuccess"), { closeButton: true });
|
||||
return refreshed;
|
||||
} catch (error) {
|
||||
toast.error(t("prompts.deleteFailed"));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[appId, reload, t, updatePromptsForApp],
|
||||
[appId, reload, t],
|
||||
);
|
||||
|
||||
const enablePrompt = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await promptsApi.enablePrompt(appId, id);
|
||||
updatePromptsForApp(appId, (current) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(current).map(([key, prompt]) => [
|
||||
key,
|
||||
{ ...prompt, enabled: key === id },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const refreshed =
|
||||
currentAppIdRef.current === appId ? await reload() : false;
|
||||
await reload();
|
||||
toast.success(t("prompts.enableSuccess"), { closeButton: true });
|
||||
return refreshed;
|
||||
} catch (error) {
|
||||
toast.error(t("prompts.enableFailed"));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[appId, reload, t, updatePromptsForApp],
|
||||
[appId, reload, t],
|
||||
);
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
async (id: string, enabled: boolean) => {
|
||||
// Optimistic update
|
||||
const previousPrompts = visiblePrompts;
|
||||
const mutationGeneration = reloadGenerationRef.current;
|
||||
const previousPrompts = prompts;
|
||||
|
||||
// 如果要启用当前提示词,先禁用其他所有提示词
|
||||
if (enabled) {
|
||||
const updatedPrompts = Object.keys(visiblePrompts).reduce(
|
||||
const updatedPrompts = Object.keys(prompts).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = {
|
||||
...visiblePrompts[key],
|
||||
...prompts[key],
|
||||
enabled: key === id,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Prompt>,
|
||||
);
|
||||
updatePromptsForApp(appId, () => updatedPrompts);
|
||||
setPrompts(updatedPrompts);
|
||||
} else {
|
||||
updatePromptsForApp(appId, (current) => ({
|
||||
...current,
|
||||
setPrompts((prev) => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
...current[id],
|
||||
...prev[id],
|
||||
enabled: false,
|
||||
},
|
||||
}));
|
||||
@@ -188,35 +108,28 @@ export function usePromptActions(appId: AppId) {
|
||||
} else {
|
||||
// 禁用提示词 - 需要后端支持
|
||||
await promptsApi.upsertPrompt(appId, id, {
|
||||
...visiblePrompts[id],
|
||||
...prompts[id],
|
||||
enabled: false,
|
||||
});
|
||||
toast.success(t("prompts.disableSuccess"), { closeButton: true });
|
||||
}
|
||||
return currentAppIdRef.current === appId ? await reload() : false;
|
||||
await reload();
|
||||
} catch (error) {
|
||||
// Rollback on failure
|
||||
if (
|
||||
currentAppIdRef.current === appId &&
|
||||
reloadGenerationRef.current === mutationGeneration
|
||||
) {
|
||||
updatePromptsForApp(appId, () => previousPrompts);
|
||||
}
|
||||
setPrompts(previousPrompts);
|
||||
toast.error(
|
||||
enabled ? t("prompts.enableFailed") : t("prompts.disableFailed"),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[appId, reload, t, updatePromptsForApp, visiblePrompts],
|
||||
[appId, prompts, reload, t],
|
||||
);
|
||||
|
||||
const importFromFile = useCallback(async () => {
|
||||
try {
|
||||
const id = await promptsApi.importFromFile(appId);
|
||||
if (currentAppIdRef.current === appId) {
|
||||
await reload();
|
||||
}
|
||||
toast.success(t("prompts.importSuccess"), { closeButton: true });
|
||||
return id;
|
||||
} catch (error) {
|
||||
@@ -226,9 +139,9 @@ export function usePromptActions(appId: AppId) {
|
||||
}, [appId, reload, t]);
|
||||
|
||||
return {
|
||||
prompts: visiblePrompts,
|
||||
prompts,
|
||||
loading,
|
||||
currentFileContent: visibleCurrentFileContent,
|
||||
currentFileContent,
|
||||
reload,
|
||||
savePrompt,
|
||||
deletePrompt,
|
||||
|
||||
@@ -11,11 +11,9 @@ export function mergeImportedSkills(
|
||||
existing: InstalledSkill[] | undefined,
|
||||
imported: InstalledSkill[],
|
||||
): InstalledSkill[] {
|
||||
if (imported.length === 0) return existing ?? imported;
|
||||
|
||||
const merged = new Map(existing?.map((skill) => [skill.id, skill]));
|
||||
for (const skill of imported) {
|
||||
merged.set(skill.id, skill);
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
if (!existing) return imported;
|
||||
if (imported.length === 0) return existing;
|
||||
const importedIds = new Set(imported.map((s) => s.id));
|
||||
const preserved = existing.filter((s) => !importedIds.has(s.id));
|
||||
return [...preserved, ...imported];
|
||||
}
|
||||
|
||||
+65
-90
@@ -15,7 +15,6 @@ import {
|
||||
} from "@/lib/api/skills";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { mergeImportedSkills } from "@/hooks/useSkills.helpers";
|
||||
import { runSequentialBulkAction } from "@/lib/utils/sequentialBulkAction";
|
||||
|
||||
/**
|
||||
* 查询所有已安装的 Skills
|
||||
@@ -43,16 +42,9 @@ export function useDeleteSkillBackup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (backupId: string) => skillsApi.deleteBackup(backupId),
|
||||
onSuccess: (_result, backupId) => {
|
||||
queryClient.setQueryData<SkillBackupEntry[]>(
|
||||
["skills", "backups"],
|
||||
(oldData) => oldData?.filter((backup) => backup.backupId !== backupId),
|
||||
);
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] });
|
||||
},
|
||||
// remove_dir_all can partially change the backup directory before
|
||||
// returning an error, so reconcile the authoritative list either way.
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,7 +64,7 @@ export function useDiscoverableSkills() {
|
||||
|
||||
/**
|
||||
* 安装 Skill
|
||||
* 成功后先合并缓存,并在结束后刷新权威列表
|
||||
* 成功后直接更新缓存,不触发重新加载/刷新
|
||||
*/
|
||||
export function useInstallSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -84,55 +76,74 @@ export function useInstallSkill() {
|
||||
skill: DiscoverableSkill;
|
||||
currentApp: AppId;
|
||||
}) => skillsApi.installUnified(skill, currentApp),
|
||||
onSuccess: (installedSkill) => {
|
||||
onSuccess: (installedSkill, _vars, _ctx) => {
|
||||
const { skill } = _vars;
|
||||
// 直接更新 installed 缓存
|
||||
queryClient.setQueryData<InstalledSkill[]>(
|
||||
["skills", "installed"],
|
||||
(oldData) => mergeImportedSkills(oldData, [installedSkill]),
|
||||
(oldData) => {
|
||||
if (!oldData) return [installedSkill];
|
||||
return [...oldData, installedSkill];
|
||||
},
|
||||
);
|
||||
|
||||
// 更新 discoverable 缓存中对应技能的 installed 状态
|
||||
const installName =
|
||||
skill.directory.split(/[/\\]/).pop()?.toLowerCase() ||
|
||||
skill.directory.toLowerCase();
|
||||
const skillKey = `${installName}:${skill.repoOwner.toLowerCase()}:${skill.repoName.toLowerCase()}`;
|
||||
|
||||
queryClient.setQueryData<DiscoverableSkill[]>(
|
||||
["skills", "discoverable"],
|
||||
(oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.map((s) => {
|
||||
if (s.key === skillKey) {
|
||||
return { ...s, installed: true };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
// The backend can persist the installation before live-config sync fails.
|
||||
// Always refresh the authoritative list, including rejected mutations.
|
||||
onSettled: () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] }),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载 Skill
|
||||
* 成功后直接移除已安装缓存,并在结束后收敛备份与未管理列表
|
||||
* 成功后直接更新缓存,不触发重新加载/刷新
|
||||
*/
|
||||
export function useUninstallSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => skillsApi.uninstallUnified(id),
|
||||
onSuccess: (_result, id) => {
|
||||
mutationFn: ({ id, skillKey }: { id: string; skillKey: string }) =>
|
||||
skillsApi
|
||||
.uninstallUnified(id)
|
||||
.then((result) => ({ ...result, skillKey })),
|
||||
onSuccess: ({ skillKey }, _vars) => {
|
||||
// 直接更新 installed 缓存,移除该 skill
|
||||
queryClient.setQueryData<InstalledSkill[]>(
|
||||
["skills", "installed"],
|
||||
(oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.filter((s) => s.id !== id);
|
||||
return oldData.filter((s) => s.id !== _vars.id);
|
||||
},
|
||||
);
|
||||
|
||||
// A completed update check may still contain this Skill. Remove it so
|
||||
// Update All cannot target an ID that was just uninstalled.
|
||||
queryClient.setQueryData<SkillUpdateInfo[]>(
|
||||
["skills", "updates"],
|
||||
(oldData) => oldData?.filter((update) => update.id !== id),
|
||||
// 更新 discoverable 缓存中对应技能的 installed 状态
|
||||
queryClient.setQueryData<DiscoverableSkill[]>(
|
||||
["skills", "discoverable"],
|
||||
(oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.map((s) => {
|
||||
if (s.key === skillKey) {
|
||||
return { ...s, installed: false };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
// Uninstall creates a backup before removing SSOT/DB state. It may reject
|
||||
// after that backup exists, and best-effort app cleanup can also leave an
|
||||
// unmanaged copy after a successful uninstall.
|
||||
onSettled: () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] }),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,11 +157,10 @@ export function useRestoreSkillBackup() {
|
||||
backupId: string;
|
||||
currentApp: AppId;
|
||||
}) => skillsApi.restoreBackup(backupId, currentApp),
|
||||
onSettled: () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] }),
|
||||
]),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,29 +179,9 @@ export function useToggleSkillApp() {
|
||||
app: AppId;
|
||||
enabled: boolean;
|
||||
}) => skillsApi.toggleApp(id, app, enabled),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle multiple Skills serially because each operation writes app files. */
|
||||
export function useBulkToggleSkillApp() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
ids,
|
||||
app,
|
||||
enabled,
|
||||
}: {
|
||||
ids: string[];
|
||||
app: AppId;
|
||||
enabled: boolean;
|
||||
}) =>
|
||||
runSequentialBulkAction(ids, (id) =>
|
||||
skillsApi.toggleApp(id, app, enabled),
|
||||
),
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,7 +205,7 @@ export function useScanUnmanagedSkills(options?: { enabled?: boolean }) {
|
||||
|
||||
/**
|
||||
* 从应用目录导入 Skills
|
||||
* 成功后先合并缓存,并在结束后刷新所有可能受影响的列表
|
||||
* 成功后直接更新缓存,不触发重新加载/刷新
|
||||
*/
|
||||
export function useImportSkillsFromApps() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -223,22 +213,14 @@ export function useImportSkillsFromApps() {
|
||||
mutationFn: (imports: ImportSkillSelection[]) =>
|
||||
skillsApi.importFromApps(imports),
|
||||
onSuccess: (importedSkills) => {
|
||||
// 直接更新 installed 缓存
|
||||
queryClient.setQueryData<InstalledSkill[]>(
|
||||
["skills", "installed"],
|
||||
(oldData) => mergeImportedSkills(oldData, importedSkills),
|
||||
);
|
||||
// 刷新 unmanaged 列表(已被导入的应该移除)
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
|
||||
},
|
||||
// Import may persist Skills or auto-discovered repositories before a
|
||||
// later item fails, so refresh every affected authoritative collection.
|
||||
onSettled: () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["skills", "discoverable"],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,7 +265,7 @@ export function useRemoveSkillRepo() {
|
||||
|
||||
/**
|
||||
* 从 ZIP 文件安装 Skills
|
||||
* 成功后先合并缓存,并在结束后刷新权威列表
|
||||
* 成功后直接更新缓存,不触发重新加载/刷新
|
||||
*/
|
||||
export function useInstallSkillsFromZip() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -296,18 +278,15 @@ export function useInstallSkillsFromZip() {
|
||||
currentApp: AppId;
|
||||
}) => skillsApi.installFromZip(filePath, currentApp),
|
||||
onSuccess: (installedSkills) => {
|
||||
// 直接更新 installed 缓存
|
||||
queryClient.setQueryData<InstalledSkill[]>(
|
||||
["skills", "installed"],
|
||||
(oldData) => mergeImportedSkills(oldData, installedSkills),
|
||||
(oldData) => {
|
||||
if (!oldData) return installedSkills;
|
||||
return [...oldData, ...installedSkills];
|
||||
},
|
||||
);
|
||||
},
|
||||
// A ZIP can install multiple Skills before a later item or config sync
|
||||
// fails, so refresh even when the mutation rejects.
|
||||
onSettled: () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] }),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -350,10 +329,6 @@ export function useUpdateSkill() {
|
||||
},
|
||||
);
|
||||
},
|
||||
// Updating creates an uninstall-style backup before replacing SSOT files;
|
||||
// refresh even when replacement or persistence fails later.
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
"import": "Import",
|
||||
"all": "All",
|
||||
"search": "Search",
|
||||
"enableAllForApp": "Enable all for {{app}}",
|
||||
"disableAllForApp": "Disable all for {{app}}",
|
||||
"bulkToggleFailed": "Failed to update {{count}} item(s)",
|
||||
"reset": "Reset",
|
||||
"actions": "Actions",
|
||||
"deleting": "Deleting...",
|
||||
@@ -1025,10 +1022,10 @@
|
||||
"providerKeyStatusLoading": "Provider identifier status is still loading. Please try again shortly.",
|
||||
"getApiKey": "Get API Key",
|
||||
"partnerPromotion": {
|
||||
"kimi": "New Kimi users get 10% of their first successful top-up back as bonus API credit, up to CNY ¥1,000.",
|
||||
"a6api": "A6API is a token aggregation platform with a built-in real-time price leaderboard that automatically picks the lowest price on the market. Smooth and stable, simple to operate, and no more tedious price comparisons. Register now to claim free trial credits!",
|
||||
"sudocode": "With one SudoCode key, Claude Code and Claude Desktop use Claude Opus 5, while Codex uses GPT-5.6. Sign up, join QQ group 726213516, and contact the group owner to claim CNY ¥10 in trial credit.",
|
||||
"code0": "code0.ai is an AI coding service platform for developers, supporting Claude Code, Codex, and Gemini. Exclusive for CC Switch users: contact support via the official website to claim free trial credits!",
|
||||
"nekocode": "NekoCode gives developers a stable, efficient, and reliable API relay for Claude, Codex, and other AI models, with transparent pay-as-you-go pricing. Exclusive 10% off for CC Switch users: register via the link above and enter promo code cc-switch at recharge to save 10%!",
|
||||
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
|
||||
"apikeyfun": "APIKEY.FUN offers a special deal for CC Switch users. Register through the exclusive link to enjoy up to permanent 5% off top-ups.",
|
||||
"apinebula": "APINEBULA offers CC Switch users a special discount: register using the link and enter the \"ccswitch\" promo code during your first top-up to get 10% off.",
|
||||
@@ -1036,7 +1033,7 @@
|
||||
"patewayai": "PatewayAI offers special benefits for CC Switch users. Register via this link to receive $3 credit.",
|
||||
"claudeapi": "ClaudeAPI offers special benefits for CC Switch users. Register via this link to claim test credits.",
|
||||
"claudecn": "ClaudeCN is an enterprise-grade AI gateway operated by a registered company, supporting enterprise procurement processes with corporate payments, contracts, and compliance guarantees.",
|
||||
"runapi": "RunAPI offers special benefits for CC Switch users. Register via this link and enjoy 10% off your first top-up.",
|
||||
"runapi": "RunAPI offers special benefits for CC Switch users. Register via this link and contact customer support to claim ¥14 free credit.",
|
||||
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
||||
"opencode_go": "Subscribe to OpenCode Go via this link — only $5 for your first month, plus an extra $5 in credit!",
|
||||
@@ -1798,9 +1795,6 @@
|
||||
"enabledApps": "Enabled Apps",
|
||||
"noImportFound": "No MCP servers to import found. All servers are already managed by CC Switch.",
|
||||
"importSuccess": "Successfully imported {{count}} MCP servers",
|
||||
"searchPlaceholder": "Search MCP name, description, tag, or command...",
|
||||
"searchAriaLabel": "Search managed MCP servers",
|
||||
"noSearchResults": "No MCP servers match your search",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -1959,9 +1953,6 @@
|
||||
"empty": "No prompts yet",
|
||||
"emptyDescription": "Click the button above to add or import prompts",
|
||||
"loading": "Loading...",
|
||||
"searchPlaceholder": "Search prompt name, description, or content...",
|
||||
"searchAriaLabel": "Search managed prompts",
|
||||
"noSearchResults": "No prompts match your search",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g., Default Project Prompt",
|
||||
"description": "Description",
|
||||
@@ -2360,9 +2351,6 @@
|
||||
},
|
||||
"search": "Search Skills",
|
||||
"searchPlaceholder": "Search skill name or repo...",
|
||||
"installedSearchPlaceholder": "Search installed skill name, description, or repo...",
|
||||
"installedSearchAriaLabel": "Search installed skills",
|
||||
"noInstalledSearchResults": "No installed skills match your search",
|
||||
"searchSource": {
|
||||
"repos": "Repos",
|
||||
"skillssh": "skills.sh"
|
||||
@@ -2512,8 +2500,6 @@
|
||||
"usageScriptWarning": "This is JavaScript that runs when usage is queried, once enabled. Import it only if you trust the source.",
|
||||
"usageApiKey": "Usage API Key",
|
||||
"usageBaseUrl": "Usage Query URL",
|
||||
"usageAccessToken": "Usage Access Token",
|
||||
"usageUserId": "Usage User ID",
|
||||
"usageAutoInterval": "Auto Query",
|
||||
"usageAutoIntervalValue": "Every {{minutes}} minutes",
|
||||
"risk": {
|
||||
@@ -2884,7 +2870,6 @@
|
||||
"noEnabledModelsWarning": "No configured models available. Configure OpenCode models first.",
|
||||
"modelSourcePartialWarning": "Some provider model configs are invalid and were skipped.",
|
||||
"modelSourceFallbackWarning": "Failed to load live provider state. Falling back to configured providers.",
|
||||
"runtimeModelsFailedWarning": "Failed to load OpenCode runtime models. Showing configured providers only.",
|
||||
"importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields",
|
||||
"importLocalFailed": "Failed to read local file: {{error}}",
|
||||
"agentKeyPlaceholder": "agent key",
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
"import": "インポート",
|
||||
"all": "すべて",
|
||||
"search": "検索",
|
||||
"enableAllForApp": "{{app}} ですべて有効化",
|
||||
"disableAllForApp": "{{app}} ですべて無効化",
|
||||
"bulkToggleFailed": "{{count}} 件の更新に失敗しました",
|
||||
"reset": "リセット",
|
||||
"actions": "操作",
|
||||
"deleting": "削除中...",
|
||||
@@ -1025,10 +1022,10 @@
|
||||
"providerKeyStatusLoading": "プロバイダー識別子の状態を読み込んでいます。しばらくしてからもう一度お試しください",
|
||||
"getApiKey": "API Key を取得",
|
||||
"partnerPromotion": {
|
||||
"kimi": "Kimi の新規ユーザーは、初回チャージに成功するとチャージ金額の 10%(最大 CNY ¥1,000)が API クレジットとして進呈されます。",
|
||||
"a6api": "A6API はトークンアグリゲーションサイトです。リアルタイム価格ランキングを内蔵し、全ネット最安値を自動で選択。動作は滑らかで安定し、面倒な価格比較も不要。登録するだけで体験クレジットがもらえます!",
|
||||
"sudocode": "SudoCode の1つのキーで、Claude Code と Claude Desktop では Claude Opus 5、Codex では GPT-5.6 を利用できます。登録後 QQ グループ 726213516 に参加し、管理者へ連絡すると CNY ¥10 のトライアルクレジットを受け取れます。",
|
||||
"code0": "code0.ai は開発者向けの AI コーディングサービスプラットフォームで、Claude Code、Codex、Gemini に対応。CC Switch ユーザー限定特典:公式サイトからサポートに連絡してテストクレジットを受け取れます!",
|
||||
"nekocode": "NekoCode は Claude や Codex などの AI モデルに対応した、安定・高効率で信頼性の高い API 中継サービスを提供します。明瞭な従量課金制。CC Switch ユーザー限定 10%オフ:上のリンクから登録し、チャージ時にクーポンコード cc-switch を入力すると 10%オフ!",
|
||||
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
|
||||
"apikeyfun": "APIKEY.FUN は CC Switch ユーザー向けに特別優待を提供しています。専用リンクから登録すると、最大でチャージ永久 5% オフを受けられます。",
|
||||
"apinebula": "APINEBULA は CC Switch ユーザー向けに特別割引を提供しています。専用リンクから登録し、チャージ時にプロモコード「ccswitch」を入力すると、さらに 10% OFF の割引が適用されます。",
|
||||
@@ -1036,7 +1033,7 @@
|
||||
"patewayai": "PatewayAI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録すると $3 のクレジットがもらえます。",
|
||||
"claudeapi": "ClaudeAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録するとテストクレジットを受け取ることができます。",
|
||||
"claudecn": "ClaudeCN は登録企業が運営するエンタープライズグレードの AI ゲートウェイプラットフォームで、企業調達プロセスをサポートし、法人支払い、契約、コンプライアンス保証を提供します。",
|
||||
"runapi": "RunAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録すると、初回チャージが 10% オフになります。",
|
||||
"runapi": "RunAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録しカスタマーサポートにご連絡いただくと、¥14 の無料クレジットを受け取ることができます。",
|
||||
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
||||
"opencode_go": "このリンクから OpenCode Go を購読すると、初月はわずか $5、さらに $5 分のクレジットがもらえます!",
|
||||
@@ -1798,9 +1795,6 @@
|
||||
"enabledApps": "有効なアプリ",
|
||||
"noImportFound": "インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。",
|
||||
"importSuccess": "{{count}} 個の MCP サーバーをインポートしました",
|
||||
"searchPlaceholder": "MCP の名前、説明、タグ、コマンドを検索...",
|
||||
"searchAriaLabel": "管理中の MCP サーバーを検索",
|
||||
"noSearchResults": "検索に一致する MCP サーバーがありません",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -1959,9 +1953,6 @@
|
||||
"empty": "まだプロンプトがありません",
|
||||
"emptyDescription": "上のボタンからプロンプトを追加またはインポートしてください",
|
||||
"loading": "読み込み中...",
|
||||
"searchPlaceholder": "プロンプトの名前、説明、内容を検索...",
|
||||
"searchAriaLabel": "管理中のプロンプトを検索",
|
||||
"noSearchResults": "検索に一致するプロンプトがありません",
|
||||
"name": "名前",
|
||||
"namePlaceholder": "例: デフォルトプロジェクトプロンプト",
|
||||
"description": "説明",
|
||||
@@ -2360,9 +2351,6 @@
|
||||
},
|
||||
"search": "スキルを検索",
|
||||
"searchPlaceholder": "スキル名またはリポジトリで検索...",
|
||||
"installedSearchPlaceholder": "インストール済みスキルの名前、説明、リポジトリを検索...",
|
||||
"installedSearchAriaLabel": "インストール済みスキルを検索",
|
||||
"noInstalledSearchResults": "検索に一致するインストール済みスキルがありません",
|
||||
"searchSource": {
|
||||
"repos": "リポジトリ",
|
||||
"skillssh": "skills.sh"
|
||||
@@ -2512,8 +2500,6 @@
|
||||
"usageScriptWarning": "これは有効化すると使用量クエリ時に実行される JavaScript です。提供元が信頼できる場合のみインポートしてください。",
|
||||
"usageApiKey": "使用量 API キー",
|
||||
"usageBaseUrl": "使用量クエリ URL",
|
||||
"usageAccessToken": "使用量アクセストークン",
|
||||
"usageUserId": "使用量ユーザー ID",
|
||||
"usageAutoInterval": "自動クエリ",
|
||||
"usageAutoIntervalValue": "{{minutes}} 分ごと",
|
||||
"risk": {
|
||||
@@ -2884,7 +2870,6 @@
|
||||
"noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。",
|
||||
"modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。",
|
||||
"modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。",
|
||||
"runtimeModelsFailedWarning": "OpenCode ランタイムモデルの取得に失敗しました。設定済みプロバイダーのモデルのみ表示します。",
|
||||
"importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました",
|
||||
"importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}",
|
||||
"agentKeyPlaceholder": "agent キー",
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
"import": "匯入",
|
||||
"all": "全部",
|
||||
"search": "搜尋",
|
||||
"enableAllForApp": "為 {{app}} 全部啟用",
|
||||
"disableAllForApp": "為 {{app}} 全部停用",
|
||||
"bulkToggleFailed": "有 {{count}} 個項目更新失敗",
|
||||
"reset": "重設",
|
||||
"actions": "操作",
|
||||
"deleting": "刪除中...",
|
||||
@@ -1026,10 +1023,10 @@
|
||||
"providerKeyStatusLoading": "正在載入供應商識別碼狀態,請稍後再試",
|
||||
"getApiKey": "取得 API Key",
|
||||
"partnerPromotion": {
|
||||
"kimi": "Kimi 新使用者首次成功儲值,即可獲贈儲值金額 10% 的 API 額度,最高贈送 ¥1000。",
|
||||
"a6api": "A6API 是一家 Token 聚合站,內建即時價格排行自動篩選全網最低價,滑順穩定不卡頓,簡易操作省去比價繁瑣,註冊獲得體驗金!",
|
||||
"sudocode": "SudoCode 讓 Claude Code 與 Claude Desktop 使用 Claude Opus 5,Codex 使用 GPT-5.6,一個 Key 統一管理。CC Switch 使用者註冊並加入 QQ 群 726213516,聯絡群主領取人民幣 ¥10 試用額度。",
|
||||
"code0": "code0.ai 是面向開發者的 AI 程式設計服務平台,支援 Claude Code、Codex、Gemini。CC Switch 使用者專屬福利:透過官網聯繫客服即可領取測試額度!",
|
||||
"nekocode": "NekoCode 為開發者提供穩定、高效、可靠的 Claude、Codex 等 AI 模型 API 中轉服務,價格透明、按量計費。CC Switch 使用者專享 9 折:透過上方連結註冊,儲值時輸入優惠碼 cc-switch 即享 9 折優惠!",
|
||||
"packycode": "PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠",
|
||||
"apikeyfun": "APIKEY.FUN 為 CC Switch 的使用者提供了特別優惠,透過專屬連結註冊,可享受最高儲值永久 95 折優惠。",
|
||||
"apinebula": "APINEBULA 為 CC Switch 使用者提供特別優惠:使用專屬連結註冊並在儲值時填寫「ccswitch」優惠碼,可享 9 折優惠。",
|
||||
@@ -1037,7 +1034,7 @@
|
||||
"patewayai": "PatewayAI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以獲得 3 美元額度。",
|
||||
"claudeapi": "ClaudeAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以領取測試額度。",
|
||||
"claudecn": "ClaudeCN 是一家實體企業營運的企業級 AI 中繼平台,支援企業採購流程,可對公打款、簽約,服務合規有保障。",
|
||||
"runapi": "RunAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊,首次儲值即可享受 9 折優惠。",
|
||||
"runapi": "RunAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊並聯繫客服可領取 ¥14 免費額度。",
|
||||
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 方案 9.9 元起",
|
||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 方案現僅 $2 / 月(2 折優惠!)",
|
||||
"opencode_go": "使用此連結訂閱 OpenCode Go,首月僅需 $5,並可獲得額外的 $5 額度!",
|
||||
@@ -1799,9 +1796,6 @@
|
||||
"enabledApps": "啟用的應用程式",
|
||||
"noImportFound": "未發現需要匯入的 MCP 伺服器。所有伺服器已在 CC Switch 統一管理中。",
|
||||
"importSuccess": "成功匯入 {{count}} 個 MCP 伺服器",
|
||||
"searchPlaceholder": "搜尋 MCP 名稱、描述、標籤或命令...",
|
||||
"searchAriaLabel": "搜尋已管理的 MCP 伺服器",
|
||||
"noSearchResults": "未找到相符的 MCP 伺服器",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -1960,9 +1954,6 @@
|
||||
"empty": "暫無提示詞",
|
||||
"emptyDescription": "點擊右上角按鈕新增或匯入提示詞",
|
||||
"loading": "載入中...",
|
||||
"searchPlaceholder": "搜尋提示詞名稱、描述或內容...",
|
||||
"searchAriaLabel": "搜尋已管理的提示詞",
|
||||
"noSearchResults": "未找到相符的提示詞",
|
||||
"name": "名稱",
|
||||
"namePlaceholder": "例如:專案預設提示詞",
|
||||
"description": "描述",
|
||||
@@ -2361,9 +2352,6 @@
|
||||
},
|
||||
"search": "搜尋技能",
|
||||
"searchPlaceholder": "搜尋技能名稱或儲存庫名稱...",
|
||||
"installedSearchPlaceholder": "搜尋已安裝技能的名稱、描述或儲存庫...",
|
||||
"installedSearchAriaLabel": "搜尋已安裝技能",
|
||||
"noInstalledSearchResults": "未找到相符的已安裝技能",
|
||||
"searchSource": {
|
||||
"repos": "儲存庫",
|
||||
"skillssh": "skills.sh"
|
||||
@@ -2513,8 +2501,6 @@
|
||||
"usageScriptWarning": "這是一段 JavaScript 程式碼,啟用後會在查詢用量時執行。請確認來源可信後再匯入。",
|
||||
"usageApiKey": "用量 API Key",
|
||||
"usageBaseUrl": "用量查詢位址",
|
||||
"usageAccessToken": "用量存取權杖",
|
||||
"usageUserId": "用量使用者 ID",
|
||||
"usageAutoInterval": "自動查詢",
|
||||
"usageAutoIntervalValue": "每 {{minutes}} 分鐘",
|
||||
"risk": {
|
||||
@@ -2885,7 +2871,6 @@
|
||||
"noEnabledModelsWarning": "目前沒有可用的已設定模型,請先設定 OpenCode 模型",
|
||||
"modelSourcePartialWarning": "部分供應商模型設定無效,已自動跳過。",
|
||||
"modelSourceFallbackWarning": "讀取 live 供應商狀態失敗,已回退至已設定供應商清單。",
|
||||
"runtimeModelsFailedWarning": "讀取 OpenCode 執行時模型失敗,已僅顯示已設定供應商模型。",
|
||||
"importLocalReplaceSuccess": "已從本地檔案匯入並覆寫 Agent/Category/Other Fields",
|
||||
"importLocalFailed": "讀取本地檔案失敗:{{error}}",
|
||||
"agentKeyPlaceholder": "agent 鍵名",
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
"import": "导入",
|
||||
"all": "全部",
|
||||
"search": "查询",
|
||||
"enableAllForApp": "为 {{app}} 全部启用",
|
||||
"disableAllForApp": "为 {{app}} 全部停用",
|
||||
"bulkToggleFailed": "有 {{count}} 项更新失败",
|
||||
"reset": "重置",
|
||||
"actions": "操作",
|
||||
"deleting": "删除中...",
|
||||
@@ -1025,10 +1022,10 @@
|
||||
"providerKeyStatusLoading": "正在加载供应商标识状态,请稍后再试",
|
||||
"getApiKey": "获取 API Key",
|
||||
"partnerPromotion": {
|
||||
"kimi": "Kimi 新用户首次成功充值,即可获赠充值金额 10% 的 API 额度,最高赠送 ¥1000。",
|
||||
"a6api": "A6API 是一家 Token 聚合站,内置实时价格排行自动筛选全网最低价,丝滑稳定不卡顿,简易操作省去比价繁琐,注册获得体验金!",
|
||||
"sudocode": "SudoCode 让 Claude Code 与 Claude Desktop 接入 Claude Opus 5,Codex 接入 GPT-5.6,一个 Key 统一使用。CC Switch 用户注册并加入 QQ 群 726213516,联系群主领取 ¥10 试用额度。",
|
||||
"code0": "code0.ai 是面向开发者的 AI 编程服务平台,支持 Claude Code、Codex、Gemini。CC Switch 用户专属福利:通过官网联系客服即可领取测试额度!",
|
||||
"nekocode": "NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明、按量计费。CC Switch 用户专享 9 折:通过上方链接注册,充值时输入优惠码 cc-switch 即享 9 折优惠!",
|
||||
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
|
||||
"apikeyfun": "APIKEY.FUN 为 CC Switch 的用户提供了特别优惠,通过专属链接注册,可享受最高充值永久 95 折优惠。",
|
||||
"apinebula": "APINEBULA 为 CC Switch 用户提供特别优惠:使用专属链接注册并在充值时填写 \"ccswitch\" 优惠码,可享九折优惠。",
|
||||
@@ -1036,7 +1033,7 @@
|
||||
"patewayai": "PatewayAI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以获得3美元额度。",
|
||||
"claudeapi": "ClaudeAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以领取测试额度。",
|
||||
"claudecn": "ClaudeCN 是一家实体企业运营的企业级AI中转平台,支持企业采购流程,可对公打款、签约,服务合规有保障。",
|
||||
"runapi": "RunAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册,首次充值即可享受 9 折优惠。",
|
||||
"runapi": "RunAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册并联系客服可领取 ¥14 免费额度。",
|
||||
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
|
||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
|
||||
"opencode_go": "使用此链接订阅 OpenCode Go,首月只需 $5,并可获得额外的 $5 额度!",
|
||||
@@ -1798,9 +1795,6 @@
|
||||
"enabledApps": "启用的应用",
|
||||
"noImportFound": "未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。",
|
||||
"importSuccess": "成功导入 {{count}} 个 MCP 服务器",
|
||||
"searchPlaceholder": "搜索 MCP 名称、描述、标签或命令...",
|
||||
"searchAriaLabel": "搜索已管理的 MCP 服务器",
|
||||
"noSearchResults": "未找到匹配的 MCP 服务器",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -1959,9 +1953,6 @@
|
||||
"empty": "暂无提示词",
|
||||
"emptyDescription": "点击右上角按钮添加或导入提示词",
|
||||
"loading": "加载中...",
|
||||
"searchPlaceholder": "搜索提示词名称、描述或内容...",
|
||||
"searchAriaLabel": "搜索已管理的提示词",
|
||||
"noSearchResults": "未找到匹配的提示词",
|
||||
"name": "名称",
|
||||
"namePlaceholder": "例如:项目默认提示词",
|
||||
"description": "描述",
|
||||
@@ -2360,9 +2351,6 @@
|
||||
},
|
||||
"search": "搜索技能",
|
||||
"searchPlaceholder": "搜索技能名称或仓库名称...",
|
||||
"installedSearchPlaceholder": "搜索已安装技能的名称、描述或仓库...",
|
||||
"installedSearchAriaLabel": "搜索已安装技能",
|
||||
"noInstalledSearchResults": "未找到匹配的已安装技能",
|
||||
"searchSource": {
|
||||
"repos": "仓库",
|
||||
"skillssh": "skills.sh"
|
||||
@@ -2512,8 +2500,6 @@
|
||||
"usageScriptWarning": "这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。",
|
||||
"usageApiKey": "用量 API Key",
|
||||
"usageBaseUrl": "用量查询地址",
|
||||
"usageAccessToken": "用量访问令牌",
|
||||
"usageUserId": "用量用户 ID",
|
||||
"usageAutoInterval": "自动查询",
|
||||
"usageAutoIntervalValue": "每 {{minutes}} 分钟",
|
||||
"risk": {
|
||||
@@ -2884,7 +2870,6 @@
|
||||
"noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型",
|
||||
"modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。",
|
||||
"modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。",
|
||||
"runtimeModelsFailedWarning": "读取 OpenCode 运行时模型失败,已仅展示已配置供应商模型。",
|
||||
"importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields",
|
||||
"importLocalFailed": "读取本地文件失败: {{error}}",
|
||||
"agentKeyPlaceholder": "agent 键名",
|
||||
|
||||
@@ -145,35 +145,6 @@ html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.status-heartbeat {
|
||||
opacity: 1;
|
||||
transition-property: color, opacity;
|
||||
transition-duration: 150ms, 300ms;
|
||||
transition-timing-function: ease, ease-out;
|
||||
}
|
||||
|
||||
html[data-window-active="true"][data-status-heartbeat="true"]
|
||||
.status-heartbeat {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
html[data-window-active="false"] .status-heartbeat {
|
||||
opacity: 1;
|
||||
transition-duration: 0s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-heartbeat {
|
||||
opacity: 1;
|
||||
transition-property: color;
|
||||
}
|
||||
|
||||
html[data-window-active="true"][data-status-heartbeat="true"]
|
||||
.status-heartbeat {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -29,16 +29,6 @@ export async function fetchModelsForConfig(
|
||||
});
|
||||
}
|
||||
|
||||
export interface OpenCodeModelRef {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/** 获取 OpenCode 当前运行时可用模型(包含 OAuth 与 Zen 免费模型)。 */
|
||||
export async function getOpenCodeModels(): Promise<OpenCodeModelRef[]> {
|
||||
return invoke("get_opencode_models");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Codex OAuth (ChatGPT Plus/Pro 反代) 可用模型列表
|
||||
*
|
||||
|
||||
@@ -298,9 +298,6 @@ export const useSwitchProviderMutation = (appId: AppId) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencode", "runtime-models"],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["omo", "current-provider-id"],
|
||||
});
|
||||
@@ -408,9 +405,6 @@ export const useSaveSettingsMutation = () => {
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencode", "runtime-models"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -112,18 +112,20 @@ export interface UseCodexOauthQuotaOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex OAuth 订阅额度查询 hook(按账号 ID)
|
||||
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook
|
||||
*
|
||||
* 直接以 cc-switch 自管的 ChatGPT 账号 ID 查询额度,供认证中心里逐个账号
|
||||
* 展示用量时复用。Query key 与 `useCodexOauthQuota` 一致,绑定到同一账号的
|
||||
* 供应商卡片与账号列表会自动去重共享同一份请求缓存。
|
||||
* 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token,
|
||||
* 而不是 Codex CLI 的 ~/.codex/auth.json。
|
||||
*
|
||||
* Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。
|
||||
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
|
||||
*/
|
||||
export function useCodexOauthQuotaByAccountId(
|
||||
accountId: string | null,
|
||||
export function useCodexOauthQuota(
|
||||
meta: ProviderMeta | undefined,
|
||||
options: UseCodexOauthQuotaOptions = {},
|
||||
) {
|
||||
const { enabled = true, autoQuery = false } = options;
|
||||
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
||||
const query = useQuery({
|
||||
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
||||
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
||||
@@ -138,21 +140,6 @@ export function useCodexOauthQuotaByAccountId(
|
||||
return useQuotaKeepLastGood(query, accountId ?? "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook
|
||||
*
|
||||
* 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token,
|
||||
* 而不是 Codex CLI 的 ~/.codex/auth.json。账号 ID 从供应商 meta 的
|
||||
* authBinding 中解析,再委托给 `useCodexOauthQuotaByAccountId`。
|
||||
*/
|
||||
export function useCodexOauthQuota(
|
||||
meta: ProviderMeta | undefined,
|
||||
options: UseCodexOauthQuotaOptions = {},
|
||||
) {
|
||||
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
||||
return useCodexOauthQuotaByAccountId(accountId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* xAI OAuth (SuperGrok 反代) 订阅额度查询 hook
|
||||
*
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
export interface SequentialBulkActionFailure<T> {
|
||||
item: T;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface SequentialBulkActionResult<T> {
|
||||
succeeded: T[];
|
||||
failed: Array<SequentialBulkActionFailure<T>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs local configuration writes in order. Several app adapters update an
|
||||
* entire config file, so parallel writes can overwrite one another.
|
||||
*/
|
||||
export async function runSequentialBulkAction<T>(
|
||||
items: readonly T[],
|
||||
action: (item: T) => Promise<unknown>,
|
||||
): Promise<SequentialBulkActionResult<T>> {
|
||||
const succeeded: T[] = [];
|
||||
const failed: Array<SequentialBulkActionFailure<T>> = [];
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await action(item);
|
||||
succeeded.push(item);
|
||||
} catch (error) {
|
||||
failed.push({ item, error });
|
||||
}
|
||||
}
|
||||
|
||||
return { succeeded, failed };
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 3000;
|
||||
const HEARTBEAT_DIM_MS = 300;
|
||||
|
||||
let initialized = false;
|
||||
let heartbeatInterval: number | undefined;
|
||||
let heartbeatReset: number | undefined;
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval !== undefined) {
|
||||
window.clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = undefined;
|
||||
}
|
||||
if (heartbeatReset !== undefined) {
|
||||
window.clearTimeout(heartbeatReset);
|
||||
heartbeatReset = undefined;
|
||||
}
|
||||
delete document.documentElement.dataset.statusHeartbeat;
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
heartbeatInterval = window.setInterval(() => {
|
||||
document.documentElement.dataset.statusHeartbeat = "true";
|
||||
heartbeatReset = window.setTimeout(() => {
|
||||
delete document.documentElement.dataset.statusHeartbeat;
|
||||
heartbeatReset = undefined;
|
||||
}, HEARTBEAT_DIM_MS);
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function setWindowActive(active: boolean) {
|
||||
document.documentElement.dataset.windowActive = String(active);
|
||||
|
||||
if (active) {
|
||||
startHeartbeat();
|
||||
} else {
|
||||
stopHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeWindowActivity() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
setWindowActive(document.hasFocus());
|
||||
|
||||
// Browser focus events are a fallback for non-Tauri renderer tests and dev mode.
|
||||
window.addEventListener("focus", () => setWindowActive(true));
|
||||
window.addEventListener("blur", () => setWindowActive(false));
|
||||
|
||||
if (isTauri()) {
|
||||
void getCurrentWindow()
|
||||
.onFocusChanged(({ payload }) => setWindowActive(payload))
|
||||
.catch((error) => {
|
||||
console.error("Failed to observe window focus changes", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
|
||||
syncModelsDevPricingOnStartup,
|
||||
} from "./lib/modelsDevAutoSync";
|
||||
import { initializeWindowActivity } from "@/lib/windowActivity";
|
||||
|
||||
installGlobalErrorHandlers();
|
||||
|
||||
@@ -115,8 +114,6 @@ async function bootstrap() {
|
||||
reportFrontendError("get_init_error", e);
|
||||
}
|
||||
|
||||
initializeWindowActivity();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<FrontendErrorBoundary>
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: { app?: string }) =>
|
||||
params?.app ? `${key}:${params.app}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AppCountBar", () => {
|
||||
it("keeps legacy counts non-interactive without a bulk callback", () => {
|
||||
render(
|
||||
<AppCountBar
|
||||
totalLabel="2 items"
|
||||
counts={{ claude: 1 }}
|
||||
appIds={["claude"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("2 items")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the whole badge to turn none or mixed into all and all into none", () => {
|
||||
const onToggleAll = vi.fn();
|
||||
const { rerender } = render(
|
||||
<AppCountBar
|
||||
totalLabel="2 items"
|
||||
totalCount={2}
|
||||
counts={{ claude: 1 }}
|
||||
appIds={["claude"]}
|
||||
onToggleAll={onToggleAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
const mixed = screen.getByRole("checkbox", {
|
||||
name: "common.enableAllForApp:Claude",
|
||||
});
|
||||
expect(mixed).toHaveAttribute("aria-checked", "mixed");
|
||||
expect(mixed).toHaveAttribute("data-selection-state", "partial");
|
||||
expect(mixed.querySelectorAll("span")).toHaveLength(2);
|
||||
expect(mixed.querySelector("svg")).not.toBeInTheDocument();
|
||||
fireEvent.click(mixed);
|
||||
expect(onToggleAll).toHaveBeenCalledWith("claude", true);
|
||||
|
||||
rerender(
|
||||
<AppCountBar
|
||||
totalLabel="2 items"
|
||||
totalCount={2}
|
||||
counts={{ claude: 2 }}
|
||||
appIds={["claude"]}
|
||||
onToggleAll={onToggleAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
const all = screen.getByRole("checkbox", {
|
||||
name: "common.disableAllForApp:Claude",
|
||||
});
|
||||
expect(all).toHaveAttribute("aria-checked", "true");
|
||||
expect(all).toHaveAttribute("data-selection-state", "all");
|
||||
expect(all.querySelectorAll("span")).toHaveLength(2);
|
||||
expect(all.querySelector("svg")).not.toBeInTheDocument();
|
||||
fireEvent.click(all);
|
||||
expect(onToggleAll).toHaveBeenLastCalledWith("claude", false);
|
||||
});
|
||||
|
||||
it("does not render a trailing selection box when no items are selected", () => {
|
||||
const onToggleAll = vi.fn();
|
||||
render(
|
||||
<AppCountBar
|
||||
totalLabel="2 items"
|
||||
totalCount={2}
|
||||
counts={{ claude: 0 }}
|
||||
appIds={["claude"]}
|
||||
onToggleAll={onToggleAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
const none = screen.getByRole("checkbox", {
|
||||
name: "common.enableAllForApp:Claude",
|
||||
});
|
||||
expect(none).toHaveAttribute("aria-checked", "false");
|
||||
expect(none).toHaveAttribute("data-selection-state", "none");
|
||||
expect(none.querySelectorAll("span")).toHaveLength(2);
|
||||
expect(none.querySelector("svg")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(none);
|
||||
expect(onToggleAll).toHaveBeenCalledWith("claude", true);
|
||||
});
|
||||
|
||||
it("disables bulk controls for an empty list or while any app is pending", () => {
|
||||
const onToggleAll = vi.fn();
|
||||
const { rerender } = render(
|
||||
<AppCountBar
|
||||
totalLabel="0 items"
|
||||
totalCount={0}
|
||||
counts={{ claude: 0 }}
|
||||
appIds={["claude"]}
|
||||
onToggleAll={onToggleAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("checkbox")).toBeDisabled();
|
||||
|
||||
rerender(
|
||||
<AppCountBar
|
||||
totalLabel="2 items"
|
||||
totalCount={2}
|
||||
counts={{ claude: 1, codex: 1 }}
|
||||
appIds={["claude", "codex"]}
|
||||
pendingApp="claude"
|
||||
onToggleAll={onToggleAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const control of screen.getAllByRole("checkbox")) {
|
||||
expect(control).toBeDisabled();
|
||||
expect(control.className).not.toContain("disabled:opacity-");
|
||||
}
|
||||
|
||||
const pendingControl = screen.getByRole("checkbox", {
|
||||
name: "common.enableAllForApp:Claude",
|
||||
});
|
||||
expect(pendingControl).toHaveAttribute("aria-busy", "true");
|
||||
expect(pendingControl.querySelectorAll("span")).toHaveLength(2);
|
||||
expect(pendingControl.querySelector("svg")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports disabling bulk controls during another management write", () => {
|
||||
render(
|
||||
<AppCountBar
|
||||
totalLabel="2 items"
|
||||
totalCount={2}
|
||||
counts={{ claude: 1 }}
|
||||
appIds={["claude"]}
|
||||
onToggleAll={vi.fn()}
|
||||
disabled
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("checkbox")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps the total and app badges in the legacy inline layout", () => {
|
||||
render(
|
||||
<AppCountBar
|
||||
totalLabel="2 items"
|
||||
totalCount={2}
|
||||
counts={{ claude: 1 }}
|
||||
appIds={["claude"]}
|
||||
onToggleAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const bar = screen.getByText("2 items").closest(".glass");
|
||||
expect(bar).toHaveClass("items-center");
|
||||
expect(bar).not.toHaveClass("flex-col");
|
||||
});
|
||||
|
||||
it("hides pointer focus rings while preserving keyboard focus styling", () => {
|
||||
render(
|
||||
<AppCountBar
|
||||
totalLabel="2 items"
|
||||
totalCount={2}
|
||||
counts={{ claude: 1 }}
|
||||
appIds={["claude"]}
|
||||
onToggleAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("checkbox")).toHaveClass(
|
||||
"select-none",
|
||||
"focus:ring-0",
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:ring-2",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
describe("AppToggleGroup", () => {
|
||||
it("exposes each app state and respects the shared disabled state", () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<AppToggleGroup
|
||||
apps={{ claude: true }}
|
||||
appIds={["claude"]}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const enabledButton = screen.getByRole("button", { name: "Claude" });
|
||||
expect(enabledButton).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(enabledButton);
|
||||
expect(onToggle).toHaveBeenCalledWith("claude", false);
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AppToggleGroup
|
||||
apps={{ claude: false }}
|
||||
appIds={["claude"]}
|
||||
onToggle={onToggle}
|
||||
disabled
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const disabledButton = screen.getByRole("button", { name: "Claude" });
|
||||
expect(disabledButton).toHaveAttribute("aria-pressed", "false");
|
||||
expect(disabledButton).toBeDisabled();
|
||||
expect(disabledButton.className).not.toContain("disabled:opacity-");
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
|
||||
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useCodexOauth: vi.fn(),
|
||||
renderAccountQuota: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({
|
||||
useCodexOauth: mocks.useCodexOauth,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/CodexOauthAccountQuota", () => ({
|
||||
default: ({ accountId }: { accountId: string }) => {
|
||||
mocks.renderAccountQuota(accountId);
|
||||
return <div data-testid="account-quota">{accountId}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
|
||||
CopilotAuthSection: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/XaiOAuthSection", () => ({
|
||||
XaiOAuthSection: () => <div />,
|
||||
}));
|
||||
|
||||
describe("CodexOAuthSection", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useCodexOauth.mockReturnValue({
|
||||
accounts: [
|
||||
{
|
||||
id: "account-1",
|
||||
provider: "codex_oauth",
|
||||
login: "user@example.com",
|
||||
avatar_url: null,
|
||||
authenticated_at: 0,
|
||||
is_default: true,
|
||||
github_domain: "",
|
||||
},
|
||||
],
|
||||
defaultAccountId: "account-1",
|
||||
hasAnyAccount: true,
|
||||
pollingState: "idle",
|
||||
deviceCode: null,
|
||||
error: null,
|
||||
isPolling: false,
|
||||
isAddingAccount: false,
|
||||
isRemovingAccount: false,
|
||||
isSettingDefaultAccount: false,
|
||||
addAccount: vi.fn(),
|
||||
removeAccount: vi.fn(),
|
||||
setDefaultAccount: vi.fn(),
|
||||
cancelAuth: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render account quota by default", () => {
|
||||
render(<CodexOAuthSection />);
|
||||
|
||||
expect(mocks.renderAccountQuota).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("account-quota")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders account quota in Auth Center", () => {
|
||||
render(<AuthCenterPanel />);
|
||||
|
||||
expect(mocks.renderAccountQuota).toHaveBeenCalledWith("account-1");
|
||||
expect(screen.getByTestId("account-quota")).toHaveTextContent("account-1");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
it("prevents cancel and confirm actions while pending", () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfirmDialog
|
||||
isOpen
|
||||
title="Confirm write"
|
||||
message="Please wait"
|
||||
pending
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "common.cancel" });
|
||||
const confirmButton = screen.getByRole("button", {
|
||||
name: "common.confirm",
|
||||
});
|
||||
expect(cancelButton).toBeDisabled();
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
fireEvent.click(cancelButton);
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { emitTauriEvent } from "../msw/tauriMocks";
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h1>{children}</h1>
|
||||
),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<p>{children}</p>
|
||||
),
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe("DeepLinkImportDialog", () => {
|
||||
it("renders masked usage access token and user id for provider imports", async () => {
|
||||
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
emitTauriEvent("deeplink-import", {
|
||||
version: "v1",
|
||||
resource: "provider",
|
||||
app: "claude",
|
||||
name: "Test Provider",
|
||||
homepage: "https://example.com",
|
||||
endpoint: "https://api.example.com",
|
||||
apiKey: "sk-provider-key",
|
||||
usageEnabled: true,
|
||||
usageScript: btoa("console.log('usage');"),
|
||||
usageApiKey: "sk-usage-key",
|
||||
usageBaseUrl: "https://usage.example.com",
|
||||
usageAccessToken: "pat-secret-token",
|
||||
usageUserId: "user-12345",
|
||||
usageAutoInterval: 60,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("用量访问令牌")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("用量用户 ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("user-12345")).toBeInTheDocument();
|
||||
// Masked: first 4 chars + 12 stars
|
||||
expect(screen.getByText("pat-************")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows usage credentials even when the deeplink carries no usageScript", async () => {
|
||||
// 后端 build_provider_meta 在任一 usage 字段存在时即持久化(含 access_token
|
||||
// 与 user_id)。若对话框只在 usageScript 存在时开门,这条链接会把凭据静默
|
||||
// 写进供应商配置。撤销门槛 widening(恢复只按 usageScript 开门)本测试即失败。
|
||||
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
emitTauriEvent("deeplink-import", {
|
||||
version: "v1",
|
||||
resource: "provider",
|
||||
app: "claude",
|
||||
name: "Token Only Provider",
|
||||
homepage: "https://example.com",
|
||||
endpoint: "https://api.example.com",
|
||||
apiKey: "sk-provider-key",
|
||||
usageAccessToken: "pat-secret-token",
|
||||
usageUserId: "user-12345",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("用量访问令牌")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("pat-************")).toBeInTheDocument();
|
||||
expect(screen.getByText("用量用户 ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("user-12345")).toBeInTheDocument();
|
||||
// 没有脚本就不应渲染脚本执行警告与脚本代码区
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("脚本代码")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ManagementListSearch } from "@/components/common/ManagementListSearch";
|
||||
|
||||
describe("ManagementListSearch", () => {
|
||||
it("reports input changes with an accessible search field", () => {
|
||||
const onValueChange = vi.fn();
|
||||
render(
|
||||
<ManagementListSearch
|
||||
value=""
|
||||
onValueChange={onValueChange}
|
||||
placeholder="Search managed items"
|
||||
ariaLabel="Search items"
|
||||
clearLabel="Clear search"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "Search items" });
|
||||
expect(input).toHaveAttribute("placeholder", "Search managed items");
|
||||
fireEvent.change(input, { target: { value: "alpha" } });
|
||||
expect(onValueChange).toHaveBeenCalledWith("alpha");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Clear search" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears a non-empty search from the button or Escape", () => {
|
||||
const onValueChange = vi.fn();
|
||||
render(
|
||||
<ManagementListSearch
|
||||
value="alpha"
|
||||
onValueChange={onValueChange}
|
||||
placeholder="Search managed items"
|
||||
ariaLabel="Search items"
|
||||
clearLabel="Clear search"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
|
||||
expect(onValueChange).toHaveBeenCalledWith("");
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("textbox", { name: "Search items" }), {
|
||||
key: "Escape",
|
||||
});
|
||||
expect(onValueChange).toHaveBeenLastCalledWith("");
|
||||
});
|
||||
});
|
||||
@@ -144,7 +144,6 @@ describe("McpFormModal", () => {
|
||||
toastErrorMock.mockClear();
|
||||
toastSuccessMock.mockClear();
|
||||
upsertMock.mockClear();
|
||||
upsertMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
const renderForm = (
|
||||
@@ -471,38 +470,4 @@ type = "stdio"
|
||||
const addButton = screen.getByText("common.add") as HTMLButtonElement;
|
||||
expect(addButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("保存进行中阻止返回按钮和 Escape 提前关闭表单", async () => {
|
||||
let resolveUpsert: (() => void) | undefined;
|
||||
upsertMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveUpsert = resolve;
|
||||
}),
|
||||
);
|
||||
const { onSave, onClose } = renderForm();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
||||
target: { value: "pending-save" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
|
||||
target: { value: '{"type":"stdio","command":"run"}' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("common.add"));
|
||||
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const backButton = document
|
||||
.querySelector("svg.lucide-arrow-left")
|
||||
?.closest("button");
|
||||
expect(backButton).not.toBeNull();
|
||||
fireEvent.click(backButton!);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
resolveUpsert?.();
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import PromptFormPanel from "@/components/prompts/PromptFormPanel";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common/FullScreenPanel", () => ({
|
||||
FullScreenPanel: ({
|
||||
onClose,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={onClose}>
|
||||
panel-close
|
||||
</button>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MarkdownEditor", () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="markdown-editor"
|
||||
value={value}
|
||||
disabled={readOnly}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("PromptFormPanel", () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
it("submits once and refuses to close while saving", async () => {
|
||||
let resolveSave!: () => void;
|
||||
const onSave = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSave = resolve;
|
||||
}),
|
||||
);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<PromptFormPanel appId="claude" onSave={onSave} onClose={onClose} />,
|
||||
);
|
||||
|
||||
const nameInput = screen.getByLabelText("prompts.name");
|
||||
fireEvent.change(nameInput, { target: { value: "My Prompt" } });
|
||||
const saveButton = screen.getByRole("button", { name: "common.save" });
|
||||
fireEvent.click(saveButton);
|
||||
fireEvent.click(saveButton);
|
||||
fireEvent.click(screen.getByRole("button", { name: "panel-close" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(nameInput).toBeDisabled();
|
||||
expect(screen.getByLabelText("markdown-editor")).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
resolveSave();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("stays open when the parent write lock rejects a save", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(false);
|
||||
const onClose = vi.fn();
|
||||
render(<PromptFormPanel appId="codex" onSave={onSave} onClose={onClose} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("prompts.name"), {
|
||||
target: { value: "Codex Prompt" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("prompts.name")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -1,648 +0,0 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import PromptPanel, {
|
||||
type PromptPanelHandle,
|
||||
} from "@/components/prompts/PromptPanel";
|
||||
import type { AppId, Prompt } from "@/lib/api";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: {
|
||||
prompts: {} as Record<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
>,
|
||||
loading: false,
|
||||
},
|
||||
reload: vi.fn(),
|
||||
getReload: vi.fn(),
|
||||
savePrompt: vi.fn(),
|
||||
deletePrompt: vi.fn(),
|
||||
toggleEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (key === "prompts.count") return `${key}:${options?.count}`;
|
||||
if (key === "prompts.enabledName") return `${key}:${options?.name}`;
|
||||
if (key === "prompts.confirm.deleteMessage") {
|
||||
return `${key}:${options?.name}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/usePromptActions", () => ({
|
||||
usePromptActions: (appId: AppId) => ({
|
||||
prompts: mocks.state.prompts,
|
||||
loading: mocks.state.loading,
|
||||
reload: mocks.getReload(appId),
|
||||
savePrompt: mocks.savePrompt,
|
||||
deletePrompt: mocks.deletePrompt,
|
||||
toggleEnabled: mocks.toggleEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useTauriEvent", () => ({
|
||||
useTauriEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/prompts/PromptFormPanel", () => ({
|
||||
default: ({
|
||||
editingId,
|
||||
initialData,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
editingId?: string;
|
||||
initialData?: Prompt;
|
||||
onSave: (id: string, prompt: Prompt) => Promise<void | boolean>;
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
<div data-testid="prompt-form">
|
||||
{editingId}:{initialData?.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const saved = await onSave(
|
||||
editingId ?? "new-prompt",
|
||||
initialData ?? {
|
||||
id: "new-prompt",
|
||||
name: "New Prompt",
|
||||
content: "New content",
|
||||
enabled: false,
|
||||
},
|
||||
);
|
||||
if (saved !== false) onClose();
|
||||
}}
|
||||
>
|
||||
form-save
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>
|
||||
form-close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConfirmDialog", () => ({
|
||||
ConfirmDialog: ({
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
pending,
|
||||
}: {
|
||||
message: string;
|
||||
onConfirm: (checked: boolean) => void;
|
||||
onCancel: () => void;
|
||||
pending?: boolean;
|
||||
}) => (
|
||||
<div role="dialog">
|
||||
<span>{message}</span>
|
||||
<button type="button" disabled={pending} onClick={() => onConfirm(false)}>
|
||||
confirm-dialog
|
||||
</button>
|
||||
<button type="button" disabled={pending} onClick={onCancel}>
|
||||
cancel-dialog
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const createPrompts = () => ({
|
||||
"record-index-47": {
|
||||
id: "payload-identifier-92",
|
||||
name: "Aurora Prompt",
|
||||
description: "Contains the nebula phrase",
|
||||
content: "Follow the quasar instruction exactly.",
|
||||
enabled: true,
|
||||
},
|
||||
"second-record": {
|
||||
id: "second-payload",
|
||||
name: "Harbor Prompt",
|
||||
description: "Deployment checklist",
|
||||
content: "Prepare the release notes.",
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
function renderPanel(appId: AppId = "claude") {
|
||||
return render(
|
||||
<PromptPanel open appId={appId} onOpenChange={() => undefined} />,
|
||||
);
|
||||
}
|
||||
|
||||
function searchFor(value: string) {
|
||||
fireEvent.change(
|
||||
screen.getByRole("textbox", { name: "prompts.searchAriaLabel" }),
|
||||
{ target: { value } },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForPanelReady() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole("switch")[0]).toBeEnabled();
|
||||
});
|
||||
}
|
||||
|
||||
describe("PromptPanel", () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.prompts = createPrompts();
|
||||
mocks.state.loading = false;
|
||||
mocks.reload.mockReset();
|
||||
mocks.reload.mockResolvedValue(true);
|
||||
mocks.getReload.mockReset();
|
||||
mocks.getReload.mockImplementation(() => mocks.reload);
|
||||
mocks.savePrompt.mockReset();
|
||||
mocks.savePrompt.mockResolvedValue(true);
|
||||
mocks.deletePrompt.mockReset();
|
||||
mocks.deletePrompt.mockResolvedValue(true);
|
||||
mocks.toggleEnabled.mockReset();
|
||||
mocks.toggleEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["record ID", "RECORD-INDEX-47"],
|
||||
["prompt ID", "PAYLOAD-IDENTIFIER-92"],
|
||||
["name", " aUrOrA "],
|
||||
["description", "NEBULA PHRASE"],
|
||||
["content", "QUASAR INSTRUCTION"],
|
||||
])("filters by %s", async (_field, query) => {
|
||||
renderPanel();
|
||||
await waitForPanelReady();
|
||||
|
||||
searchFor(query);
|
||||
|
||||
expect(screen.getByText("Aurora Prompt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Harbor Prompt")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("distinguishes an empty prompt collection from no search matches", async () => {
|
||||
const view = renderPanel();
|
||||
await waitForPanelReady();
|
||||
|
||||
searchFor("does-not-exist");
|
||||
expect(screen.getByText("prompts.noSearchResults")).toBeInTheDocument();
|
||||
expect(screen.queryByText("prompts.empty")).not.toBeInTheDocument();
|
||||
|
||||
mocks.state.prompts = {};
|
||||
view.rerender(
|
||||
<PromptPanel open appId="claude" onOpenChange={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("prompts.empty")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("prompts.noSearchResults"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the query and restores all prompts", async () => {
|
||||
renderPanel();
|
||||
await waitForPanelReady();
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "prompts.searchAriaLabel",
|
||||
});
|
||||
|
||||
searchFor("aurora");
|
||||
expect(screen.queryByText("Harbor Prompt")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.clear" }));
|
||||
|
||||
expect(input).toHaveValue("");
|
||||
expect(screen.getByText("Aurora Prompt")).toBeInTheDocument();
|
||||
expect(screen.getByText("Harbor Prompt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the query when the app changes", async () => {
|
||||
const view = renderPanel("claude");
|
||||
await waitForPanelReady();
|
||||
searchFor("aurora");
|
||||
|
||||
view.rerender(
|
||||
<PromptPanel open appId="codex" onOpenChange={() => undefined} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "prompts.searchAriaLabel" }),
|
||||
).toHaveValue("");
|
||||
});
|
||||
await waitForPanelReady();
|
||||
expect(screen.getByText("Aurora Prompt")).toBeInTheDocument();
|
||||
expect(screen.getByText("Harbor Prompt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps totals and the enabled prompt based on the full collection", async () => {
|
||||
const { container } = renderPanel();
|
||||
await waitForPanelReady();
|
||||
|
||||
searchFor("harbor");
|
||||
|
||||
const summary = container.querySelector(".glass .text-sm");
|
||||
expect(summary).toHaveTextContent("prompts.count:2");
|
||||
expect(summary).toHaveTextContent("prompts.enabledName:Aurora Prompt");
|
||||
expect(screen.queryByText("Aurora Prompt")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves record IDs for filtered toggle, edit, and delete actions", async () => {
|
||||
renderPanel();
|
||||
await waitForPanelReady();
|
||||
searchFor("quasar instruction");
|
||||
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
expect(mocks.toggleEnabled).toHaveBeenCalledWith("record-index-47", false);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("common.edit")).toBeEnabled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("common.edit"));
|
||||
expect(screen.getByTestId("prompt-form")).toHaveTextContent(
|
||||
"record-index-47:Aurora Prompt",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "form-close" }));
|
||||
|
||||
fireEvent.click(screen.getByTitle("common.delete"));
|
||||
expect(
|
||||
screen.getByText("prompts.confirm.deleteMessage:Aurora Prompt"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "confirm-dialog" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.deletePrompt).toHaveBeenCalledWith("record-index-47");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the search field outside the scrollable viewport", async () => {
|
||||
const { container } = renderPanel();
|
||||
await waitForPanelReady();
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "prompts.searchAriaLabel",
|
||||
});
|
||||
const viewport = container.querySelector(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
);
|
||||
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(viewport).not.toContainElement(input);
|
||||
});
|
||||
|
||||
it("serializes toggle writes and reports the interaction as blocked", async () => {
|
||||
let resolveToggle!: () => void;
|
||||
mocks.toggleEnabled.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveToggle = resolve;
|
||||
}),
|
||||
);
|
||||
const onInteractionBlockedChange = vi.fn();
|
||||
const ref = createRef<PromptPanelHandle>();
|
||||
render(
|
||||
<PromptPanel
|
||||
ref={ref}
|
||||
open
|
||||
appId="claude"
|
||||
onOpenChange={() => undefined}
|
||||
onInteractionBlockedChange={onInteractionBlockedChange}
|
||||
/>,
|
||||
);
|
||||
await waitForPanelReady();
|
||||
|
||||
const toggle = screen.getAllByRole("switch")[0];
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(mocks.toggleEnabled).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(onInteractionBlockedChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
expect(toggle).toBeDisabled();
|
||||
expect(screen.getAllByTitle("common.edit")[0]).toBeDisabled();
|
||||
expect(screen.getAllByTitle("common.delete")[0]).toBeDisabled();
|
||||
|
||||
act(() => ref.current?.openAdd());
|
||||
expect(screen.queryByTestId("prompt-form")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveToggle();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onInteractionBlockedChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks all prompt actions while the collection is loading", async () => {
|
||||
mocks.state.loading = true;
|
||||
const ref = createRef<PromptPanelHandle>();
|
||||
const onInteractionBlockedChange = vi.fn();
|
||||
render(
|
||||
<PromptPanel
|
||||
ref={ref}
|
||||
open
|
||||
appId="claude"
|
||||
onOpenChange={() => undefined}
|
||||
onInteractionBlockedChange={onInteractionBlockedChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInteractionBlockedChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("common.edit")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("common.delete")).not.toBeInTheDocument();
|
||||
|
||||
act(() => ref.current?.openAdd());
|
||||
expect(screen.queryByTestId("prompt-form")).not.toBeInTheDocument();
|
||||
expect(mocks.toggleEnabled).not.toHaveBeenCalled();
|
||||
expect(mocks.savePrompt).not.toHaveBeenCalled();
|
||||
expect(mocks.deletePrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues external reloads until the active write finishes", async () => {
|
||||
renderPanel();
|
||||
await waitForPanelReady();
|
||||
expect(mocks.reload).toHaveBeenCalledTimes(1);
|
||||
mocks.reload.mockClear();
|
||||
|
||||
let resolveToggle!: () => void;
|
||||
mocks.toggleEnabled.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveToggle = resolve;
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole("switch")[0]);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("prompt-imported", { detail: { app: "claude" } }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("prompt-imported", { detail: { app: "claude" } }),
|
||||
);
|
||||
});
|
||||
expect(mocks.reload).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveToggle();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("runs one compensating reload when a toggle write cannot refresh", async () => {
|
||||
renderPanel();
|
||||
await waitForPanelReady();
|
||||
mocks.reload.mockClear();
|
||||
mocks.toggleEnabled.mockResolvedValueOnce(false);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("switch")[0]);
|
||||
|
||||
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("runs one compensating reload when a delete write cannot refresh", async () => {
|
||||
renderPanel();
|
||||
await waitForPanelReady();
|
||||
mocks.reload.mockClear();
|
||||
mocks.deletePrompt.mockResolvedValueOnce(false);
|
||||
|
||||
fireEvent.click(screen.getAllByTitle("common.delete")[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "confirm-dialog" }));
|
||||
|
||||
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("runs one compensating reload when a save write cannot refresh", async () => {
|
||||
renderPanel();
|
||||
await waitForPanelReady();
|
||||
mocks.reload.mockClear();
|
||||
mocks.savePrompt.mockResolvedValueOnce(false);
|
||||
|
||||
fireEvent.click(screen.getAllByTitle("common.edit")[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "form-save" }));
|
||||
|
||||
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("allows navigation but blocks interactions during a pure reload", async () => {
|
||||
let resolveReload!: (value: boolean) => void;
|
||||
mocks.reload.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveReload = resolve;
|
||||
}),
|
||||
);
|
||||
const onInteractionBlockedChange = vi.fn();
|
||||
const onNavigationBlockedChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PromptPanel
|
||||
open
|
||||
appId="claude"
|
||||
onOpenChange={() => undefined}
|
||||
onInteractionBlockedChange={onInteractionBlockedChange}
|
||||
onNavigationBlockedChange={onNavigationBlockedChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.reload).toHaveBeenCalledTimes(1);
|
||||
expect(onInteractionBlockedChange).toHaveBeenLastCalledWith(true);
|
||||
expect(onNavigationBlockedChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveReload(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onInteractionBlockedChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
expect(onNavigationBlockedChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("queues external reloads while an edit or confirmation is open", async () => {
|
||||
renderPanel();
|
||||
await waitForPanelReady();
|
||||
mocks.reload.mockClear();
|
||||
|
||||
fireEvent.click(screen.getAllByTitle("common.edit")[0]);
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("prompt-imported", { detail: { app: "claude" } }),
|
||||
);
|
||||
});
|
||||
expect(mocks.reload).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form-close" }));
|
||||
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
|
||||
mocks.reload.mockClear();
|
||||
await waitForPanelReady();
|
||||
|
||||
fireEvent.click(screen.getAllByTitle("common.delete")[0]);
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("prompt-imported", { detail: { app: "claude" } }),
|
||||
);
|
||||
});
|
||||
expect(mocks.reload).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "cancel-dialog" }));
|
||||
await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("starts the latest app reload without waiting for an older app", async () => {
|
||||
let resolveClaudeReload!: () => void;
|
||||
const claudeReload = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveClaudeReload = resolve;
|
||||
}),
|
||||
);
|
||||
const codexReload = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.getReload.mockImplementation((appId: AppId) =>
|
||||
appId === "codex" ? codexReload : claudeReload,
|
||||
);
|
||||
|
||||
const ref = createRef<PromptPanelHandle>();
|
||||
const view = render(
|
||||
<PromptPanel
|
||||
ref={ref}
|
||||
open
|
||||
appId="claude"
|
||||
onOpenChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(claudeReload).toHaveBeenCalledTimes(1));
|
||||
act(() => ref.current?.openAdd());
|
||||
expect(screen.queryByTestId("prompt-form")).not.toBeInTheDocument();
|
||||
|
||||
view.rerender(
|
||||
<PromptPanel
|
||||
ref={ref}
|
||||
open
|
||||
appId="codex"
|
||||
onOpenChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(codexReload).toHaveBeenCalledTimes(1));
|
||||
expect(claudeReload).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveClaudeReload();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(codexReload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps delete confirmation pending and ignores duplicate confirms", async () => {
|
||||
let resolveDelete!: () => void;
|
||||
mocks.deletePrompt.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveDelete = resolve;
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
await waitForPanelReady();
|
||||
|
||||
fireEvent.click(screen.getAllByTitle("common.delete")[0]);
|
||||
const confirm = screen.getByRole("button", { name: "confirm-dialog" });
|
||||
const cancel = screen.getByRole("button", { name: "cancel-dialog" });
|
||||
fireEvent.click(confirm);
|
||||
fireEvent.click(confirm);
|
||||
|
||||
expect(mocks.deletePrompt).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(confirm).toBeDisabled();
|
||||
expect(cancel).toBeDisabled();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveDelete();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("locks form saves and cannot close the form while a save is pending", async () => {
|
||||
let resolveSave!: () => void;
|
||||
mocks.savePrompt.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSave = resolve;
|
||||
}),
|
||||
);
|
||||
const onInteractionBlockedChange = vi.fn();
|
||||
render(
|
||||
<PromptPanel
|
||||
open
|
||||
appId="claude"
|
||||
onOpenChange={() => undefined}
|
||||
onInteractionBlockedChange={onInteractionBlockedChange}
|
||||
/>,
|
||||
);
|
||||
await waitForPanelReady();
|
||||
|
||||
fireEvent.click(screen.getAllByTitle("common.edit")[0]);
|
||||
const save = screen.getByRole("button", { name: "form-save" });
|
||||
fireEvent.click(save);
|
||||
fireEvent.click(save);
|
||||
fireEvent.click(screen.getByRole("button", { name: "form-close" }));
|
||||
|
||||
expect(mocks.savePrompt).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("prompt-form")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(onInteractionBlockedChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveSave();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("prompt-form")).not.toBeInTheDocument();
|
||||
expect(onInteractionBlockedChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes stale forms and confirmations when the app changes", async () => {
|
||||
const view = renderPanel("claude");
|
||||
await waitForPanelReady();
|
||||
|
||||
fireEvent.click(screen.getAllByTitle("common.edit")[0]);
|
||||
expect(screen.getByTestId("prompt-form")).toBeInTheDocument();
|
||||
|
||||
view.rerender(
|
||||
<PromptPanel open appId="codex" onOpenChange={() => undefined} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("prompt-form")).not.toBeInTheDocument();
|
||||
});
|
||||
await waitForPanelReady();
|
||||
|
||||
fireEvent.click(screen.getAllByTitle("common.delete")[0]);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
view.rerender(
|
||||
<PromptPanel open appId="gemini" onOpenChange={() => undefined} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||
import type { McpApps, McpServer, McpServerSpec } from "@/types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
serversMap: {} as Record<string, McpServer>,
|
||||
isLoading: false,
|
||||
togglePending: false,
|
||||
toggleVariables: undefined as
|
||||
| { serverId: string; app: string; enabled: boolean }
|
||||
| undefined,
|
||||
bulkPending: false,
|
||||
bulkVariables: undefined as
|
||||
| { serverIds: string[]; app: string; enabled: boolean }
|
||||
| undefined,
|
||||
toggle: vi.fn(),
|
||||
bulkToggle: vi.fn(),
|
||||
deleteServer: vi.fn(),
|
||||
importServers: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useMcp", () => ({
|
||||
useAllMcpServers: () => ({
|
||||
data: mocks.serversMap,
|
||||
isLoading: mocks.isLoading,
|
||||
}),
|
||||
useToggleMcpApp: () => ({
|
||||
mutateAsync: mocks.toggle,
|
||||
isPending: mocks.togglePending,
|
||||
variables: mocks.toggleVariables,
|
||||
}),
|
||||
useBulkToggleMcpApp: () => ({
|
||||
mutateAsync: mocks.bulkToggle,
|
||||
isPending: mocks.bulkPending,
|
||||
variables: mocks.bulkVariables,
|
||||
}),
|
||||
useDeleteMcpServer: () => ({ mutateAsync: mocks.deleteServer }),
|
||||
useImportMcpFromApps: () => ({ mutateAsync: mocks.importServers }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/mcp/McpFormModal", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
},
|
||||
}));
|
||||
|
||||
type ServerOverrides = Partial<Omit<McpServer, "apps" | "server">> & {
|
||||
apps?: Partial<McpApps>;
|
||||
server?: Partial<McpServerSpec>;
|
||||
};
|
||||
|
||||
function makeServer(id: string, overrides: ServerOverrides = {}): McpServer {
|
||||
const { apps, server, ...metadata } = overrides;
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
...metadata,
|
||||
server: {
|
||||
type: "stdio",
|
||||
command: "default-command",
|
||||
...server,
|
||||
},
|
||||
apps: {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
...apps,
|
||||
},
|
||||
} as McpServer;
|
||||
}
|
||||
|
||||
function renderPanel(onInteractionBlockedChange?: (blocked: boolean) => void) {
|
||||
return render(
|
||||
<UnifiedMcpPanel
|
||||
onOpenChange={vi.fn()}
|
||||
onInteractionBlockedChange={onInteractionBlockedChange}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UnifiedMcpPanel", () => {
|
||||
beforeEach(() => {
|
||||
mocks.serversMap = {};
|
||||
mocks.isLoading = false;
|
||||
mocks.togglePending = false;
|
||||
mocks.toggleVariables = undefined;
|
||||
mocks.bulkPending = false;
|
||||
mocks.bulkVariables = undefined;
|
||||
mocks.toggle.mockReset();
|
||||
mocks.bulkToggle.mockReset();
|
||||
mocks.deleteServer.mockReset();
|
||||
mocks.importServers.mockReset();
|
||||
mocks.toastError.mockReset();
|
||||
mocks.toastSuccess.mockReset();
|
||||
mocks.toggle.mockResolvedValue(undefined);
|
||||
mocks.bulkToggle.mockResolvedValue({ succeeded: [], failed: [] });
|
||||
});
|
||||
|
||||
it("searches the explicit non-sensitive MCP fields and renders a visible ScrollArea", () => {
|
||||
mocks.serversMap = {
|
||||
"map-key-hit": makeServer("internal-id-hit", {
|
||||
name: "Display Name Hit",
|
||||
description: "description-hit",
|
||||
tags: ["tag-hit"],
|
||||
homepage: "https://homepage-hit.example",
|
||||
docs: "https://docs-hit.example",
|
||||
source: "source-hit",
|
||||
server: {
|
||||
type: "sse",
|
||||
command: "command-hit",
|
||||
args: ["--arg-hit"],
|
||||
cwd: "/cwd-hit",
|
||||
url: "https://url-hit.example",
|
||||
},
|
||||
}),
|
||||
control: makeServer("control", { name: "Control Server" }),
|
||||
};
|
||||
|
||||
const { container } = renderPanel();
|
||||
const input = screen.getByLabelText("mcp.unifiedPanel.searchAriaLabel");
|
||||
|
||||
expect(
|
||||
container.querySelector("[data-radix-scroll-area-viewport]"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
for (const query of [
|
||||
"map-key-hit",
|
||||
"internal-id-hit",
|
||||
" DISPLAY NAME HIT ",
|
||||
"description-hit",
|
||||
"tag-hit",
|
||||
"sse",
|
||||
"command-hit",
|
||||
"arg-hit",
|
||||
"cwd-hit",
|
||||
"url-hit.example",
|
||||
"homepage-hit.example",
|
||||
"docs-hit.example",
|
||||
"source-hit",
|
||||
]) {
|
||||
fireEvent.change(input, { target: { value: query } });
|
||||
expect(screen.getByText("Display Name Hit")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Control Server")).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not index MCP env or headers keys and values", () => {
|
||||
mocks.serversMap = {
|
||||
secret: makeServer("secret", {
|
||||
name: "Secret Holder",
|
||||
server: {
|
||||
env: { ONLY_ENV_SECRET: "env-value-needle" },
|
||||
headers: { Authorization: "header-value-needle" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
renderPanel();
|
||||
const input = screen.getByLabelText("mcp.unifiedPanel.searchAriaLabel");
|
||||
|
||||
for (const query of [
|
||||
"only_env_secret",
|
||||
"env-value-needle",
|
||||
"authorization",
|
||||
"header-value-needle",
|
||||
]) {
|
||||
fireEvent.change(input, { target: { value: query } });
|
||||
expect(screen.queryByText("Secret Holder")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("mcp.unifiedPanel.noSearchResults"),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the original empty state distinct from an empty search result", () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText("mcp.unifiedPanel.noServers")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("mcp.unifiedPanel.noSearchResults"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByLabelText("mcp.unifiedPanel.searchAriaLabel"),
|
||||
{ target: { value: "anything" } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("mcp.unifiedPanel.noServers")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("mcp.unifiedPanel.noSearchResults"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bulk toggles the full collection and submits only servers whose state differs", async () => {
|
||||
mocks.serversMap = {
|
||||
visible: makeServer("visible", {
|
||||
name: "Visible Needle",
|
||||
apps: { claude: false },
|
||||
}),
|
||||
"hidden-disabled": makeServer("hidden-disabled", {
|
||||
name: "Hidden Disabled",
|
||||
apps: { claude: false },
|
||||
}),
|
||||
"hidden-enabled": makeServer("hidden-enabled", {
|
||||
name: "Hidden Enabled",
|
||||
apps: { claude: true },
|
||||
}),
|
||||
};
|
||||
mocks.bulkToggle.mockResolvedValue({
|
||||
succeeded: ["visible", "hidden-disabled"],
|
||||
failed: [],
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
fireEvent.change(
|
||||
screen.getByLabelText("mcp.unifiedPanel.searchAriaLabel"),
|
||||
{ target: { value: "visible needle" } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Visible Needle")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Hidden Disabled")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Hidden Enabled")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.bulkToggle).toHaveBeenCalledWith({
|
||||
serverIds: ["visible", "hidden-disabled"],
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks edit and delete while a toggle write is pending", async () => {
|
||||
mocks.serversMap = {
|
||||
server: makeServer("server", { name: "Managed Server" }),
|
||||
};
|
||||
mocks.bulkPending = true;
|
||||
mocks.bulkVariables = {
|
||||
serverIds: ["server"],
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
};
|
||||
const onInteractionBlockedChange = vi.fn();
|
||||
|
||||
renderPanel(onInteractionBlockedChange);
|
||||
|
||||
expect(screen.getByTitle("common.edit")).toBeDisabled();
|
||||
expect(screen.getByTitle("common.delete")).toBeDisabled();
|
||||
for (const bulkControl of screen.getAllByRole("checkbox")) {
|
||||
expect(bulkControl).toBeDisabled();
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(onInteractionBlockedChange).toHaveBeenCalledWith(true),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,10 @@
|
||||
import { createRef } from "react";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import UnifiedSkillsPanel, {
|
||||
type UnifiedSkillsPanelHandle,
|
||||
} from "@/components/skills/UnifiedSkillsPanel";
|
||||
import type {
|
||||
InstalledSkill,
|
||||
SkillBackupEntry,
|
||||
SkillUpdateInfo,
|
||||
} from "@/lib/api/skills";
|
||||
|
||||
const scanUnmanagedMock = vi.fn();
|
||||
const toggleSkillAppMock = vi.fn();
|
||||
@@ -19,43 +13,23 @@ const importSkillsMock = vi.fn();
|
||||
const installFromZipMock = vi.fn();
|
||||
const deleteSkillBackupMock = vi.fn();
|
||||
const restoreSkillBackupMock = vi.fn();
|
||||
const bulkToggleSkillAppMock = vi.fn();
|
||||
const checkUpdatesMock = vi.fn();
|
||||
const updateSkillMock = vi.fn();
|
||||
const refetchSkillBackupsMock = vi.fn();
|
||||
const { toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({
|
||||
toastErrorMock: vi.fn(),
|
||||
toastSuccessMock: vi.fn(),
|
||||
}));
|
||||
let installedSkillsMock: InstalledSkill[] = [];
|
||||
let skillBackupsMock: SkillBackupEntry[] = [];
|
||||
let skillUpdatesMock: SkillUpdateInfo[] = [];
|
||||
let checkUpdatesFetching = false;
|
||||
let toggleSkillAppPending = false;
|
||||
let toggleSkillAppVariables:
|
||||
| { id: string; app: "claude"; enabled: boolean }
|
||||
| undefined;
|
||||
let bulkToggleSkillAppPending = false;
|
||||
let bulkToggleSkillAppVariables:
|
||||
| { ids: string[]; app: "claude"; enabled: boolean }
|
||||
| undefined;
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: toastSuccessMock,
|
||||
error: toastErrorMock,
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useSkills", () => ({
|
||||
useInstalledSkills: () => ({
|
||||
data: installedSkillsMock,
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
useSkillBackups: () => ({
|
||||
data: skillBackupsMock,
|
||||
refetch: refetchSkillBackupsMock,
|
||||
data: [],
|
||||
refetch: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
useDeleteSkillBackup: () => ({
|
||||
@@ -64,13 +38,6 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
}),
|
||||
useToggleSkillApp: () => ({
|
||||
mutateAsync: toggleSkillAppMock,
|
||||
isPending: toggleSkillAppPending,
|
||||
variables: toggleSkillAppVariables,
|
||||
}),
|
||||
useBulkToggleSkillApp: () => ({
|
||||
mutateAsync: bulkToggleSkillAppMock,
|
||||
isPending: bulkToggleSkillAppPending,
|
||||
variables: bulkToggleSkillAppVariables,
|
||||
}),
|
||||
useRestoreSkillBackup: () => ({
|
||||
mutateAsync: restoreSkillBackupMock,
|
||||
@@ -98,63 +65,18 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
mutateAsync: installFromZipMock,
|
||||
}),
|
||||
useCheckSkillUpdates: () => ({
|
||||
data: skillUpdatesMock,
|
||||
refetch: checkUpdatesMock,
|
||||
isFetching: checkUpdatesFetching,
|
||||
data: [],
|
||||
refetch: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
useUpdateSkill: () => ({
|
||||
mutateAsync: updateSkillMock,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
type InstalledSkillOverrides = Omit<Partial<InstalledSkill>, "apps"> & {
|
||||
apps?: Partial<InstalledSkill["apps"]>;
|
||||
};
|
||||
|
||||
const makeInstalledSkill = (
|
||||
overrides: InstalledSkillOverrides = {},
|
||||
): InstalledSkill => {
|
||||
const defaultApps: InstalledSkill["apps"] = {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
};
|
||||
const { apps, ...skillOverrides } = overrides;
|
||||
|
||||
return {
|
||||
id: "owner/repo:alpha-skill",
|
||||
name: "Alpha Skill",
|
||||
description: "Alpha description",
|
||||
directory: "alpha-skill",
|
||||
repoOwner: "owner",
|
||||
repoName: "repo",
|
||||
repoBranch: "main",
|
||||
apps: { ...defaultApps, ...apps },
|
||||
installedAt: 1,
|
||||
updatedAt: 1,
|
||||
...skillOverrides,
|
||||
};
|
||||
};
|
||||
|
||||
const renderPanel = () =>
|
||||
render(<UnifiedSkillsPanel onOpenDiscovery={() => {}} currentApp="claude" />);
|
||||
|
||||
describe("UnifiedSkillsPanel", () => {
|
||||
beforeEach(() => {
|
||||
installedSkillsMock = [];
|
||||
skillBackupsMock = [];
|
||||
skillUpdatesMock = [];
|
||||
checkUpdatesFetching = false;
|
||||
toggleSkillAppPending = false;
|
||||
toggleSkillAppVariables = undefined;
|
||||
bulkToggleSkillAppPending = false;
|
||||
bulkToggleSkillAppVariables = undefined;
|
||||
scanUnmanagedMock.mockReset();
|
||||
scanUnmanagedMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -167,24 +89,11 @@ describe("UnifiedSkillsPanel", () => {
|
||||
],
|
||||
});
|
||||
toggleSkillAppMock.mockReset();
|
||||
toggleSkillAppMock.mockResolvedValue(true);
|
||||
bulkToggleSkillAppMock.mockReset();
|
||||
bulkToggleSkillAppMock.mockResolvedValue({ succeeded: [], failed: [] });
|
||||
toastErrorMock.mockReset();
|
||||
toastSuccessMock.mockReset();
|
||||
uninstallSkillMock.mockReset();
|
||||
importSkillsMock.mockReset();
|
||||
installFromZipMock.mockReset();
|
||||
deleteSkillBackupMock.mockReset();
|
||||
refetchSkillBackupsMock.mockReset();
|
||||
refetchSkillBackupsMock.mockResolvedValue({ data: skillBackupsMock });
|
||||
restoreSkillBackupMock.mockReset();
|
||||
checkUpdatesMock.mockReset();
|
||||
checkUpdatesMock.mockResolvedValue({ data: [] });
|
||||
updateSkillMock.mockReset();
|
||||
updateSkillMock.mockImplementation(async (id: string) =>
|
||||
makeInstalledSkill({ id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the import dialog without crashing when app toggles render", async () => {
|
||||
@@ -221,535 +130,4 @@ describe("UnifiedSkillsPanel", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes only the installed Skill ID to uninstall", async () => {
|
||||
installedSkillsMock = [
|
||||
makeInstalledSkill({
|
||||
id: "owner/repo:skill-id",
|
||||
directory: "nested/skill-directory",
|
||||
repoOwner: "owner",
|
||||
repoName: "repo",
|
||||
}),
|
||||
];
|
||||
uninstallSkillMock.mockResolvedValueOnce({ backupPath: undefined });
|
||||
renderPanel();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTitle("skills.uninstall"));
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "common.confirm",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uninstallSkillMock).toHaveBeenCalledWith("owner/repo:skill-id");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["name", "searchable name"],
|
||||
["id", "opaque-id-token"],
|
||||
["description", "descriptive-token"],
|
||||
["directory", "directory-token"],
|
||||
["repo owner", "owner-token"],
|
||||
["repo name", "repository-token"],
|
||||
])("filters installed Skills by %s", async (_field, query) => {
|
||||
installedSkillsMock = [
|
||||
makeInstalledSkill({
|
||||
id: "opaque-id-token",
|
||||
name: "Searchable Name",
|
||||
description: "Contains descriptive-token",
|
||||
directory: "nested/directory-token",
|
||||
repoOwner: "owner-token",
|
||||
repoName: "repository-token",
|
||||
}),
|
||||
makeInstalledSkill({
|
||||
id: "unrelated-id",
|
||||
name: "Unrelated Skill",
|
||||
description: "Nothing to match",
|
||||
directory: "other-directory",
|
||||
repoOwner: "another-owner",
|
||||
repoName: "another-repo",
|
||||
}),
|
||||
];
|
||||
renderPanel();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", {
|
||||
name: "skills.installedSearchAriaLabel",
|
||||
}),
|
||||
` ${query.toUpperCase()} `,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Searchable Name")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Unrelated Skill")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("distinguishes an empty list from an installed-Skill search miss", async () => {
|
||||
const { rerender } = renderPanel();
|
||||
|
||||
expect(screen.getByText("skills.noInstalled")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("skills.noInstalledSearchResults"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
installedSkillsMock = [makeInstalledSkill()];
|
||||
rerender(
|
||||
<UnifiedSkillsPanel onOpenDiscovery={() => {}} currentApp="claude" />,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", {
|
||||
name: "skills.installedSearchAriaLabel",
|
||||
}),
|
||||
"missing",
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("skills.noInstalledSearchResults"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("skills.noInstalled")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the search control outside the visible scroll viewport", () => {
|
||||
installedSkillsMock = [makeInstalledSkill()];
|
||||
const { container } = renderPanel();
|
||||
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: "skills.installedSearchAriaLabel",
|
||||
});
|
||||
const viewport = container.querySelector(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
);
|
||||
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(viewport).not.toContainElement(searchInput);
|
||||
});
|
||||
|
||||
it("enables only disabled Skills from the full list when the app state is mixed", async () => {
|
||||
installedSkillsMock = [
|
||||
makeInstalledSkill({
|
||||
id: "enabled-id",
|
||||
name: "Visible Skill",
|
||||
apps: { claude: true },
|
||||
}),
|
||||
makeInstalledSkill({ id: "disabled-id-1", name: "Hidden Skill One" }),
|
||||
makeInstalledSkill({ id: "disabled-id-2", name: "Hidden Skill Two" }),
|
||||
];
|
||||
bulkToggleSkillAppMock.mockResolvedValue({
|
||||
succeeded: ["disabled-id-1", "disabled-id-2"],
|
||||
failed: [],
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", {
|
||||
name: "skills.installedSearchAriaLabel",
|
||||
}),
|
||||
"Visible Skill",
|
||||
);
|
||||
await user.click(screen.getByText("Claude:").closest("button")!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bulkToggleSkillAppMock).toHaveBeenCalledWith({
|
||||
ids: ["disabled-id-1", "disabled-id-2"],
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("enables all Skills when none are enabled for an app", async () => {
|
||||
installedSkillsMock = [
|
||||
makeInstalledSkill({ id: "first-id" }),
|
||||
makeInstalledSkill({ id: "second-id" }),
|
||||
];
|
||||
renderPanel();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText("Claude:").closest("button")!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bulkToggleSkillAppMock).toHaveBeenCalledWith({
|
||||
ids: ["first-id", "second-id"],
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("disables all Skills when every Skill is enabled for an app", async () => {
|
||||
installedSkillsMock = [
|
||||
makeInstalledSkill({ id: "first-id", apps: { claude: true } }),
|
||||
makeInstalledSkill({ id: "second-id", apps: { claude: true } }),
|
||||
];
|
||||
renderPanel();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText("Claude:").closest("button")!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bulkToggleSkillAppMock).toHaveBeenCalledWith({
|
||||
ids: ["first-id", "second-id"],
|
||||
app: "claude",
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("reports partial bulk-toggle failures", async () => {
|
||||
installedSkillsMock = [
|
||||
makeInstalledSkill({ id: "first-id" }),
|
||||
makeInstalledSkill({ id: "second-id" }),
|
||||
];
|
||||
bulkToggleSkillAppMock.mockResolvedValue({
|
||||
succeeded: ["first-id"],
|
||||
failed: [{ item: "second-id", error: new Error("permission denied") }],
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText("Claude:").closest("button")!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("common.bulkToggleFailed", {
|
||||
description: "Error: permission denied",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["single", "bulk"] as const)(
|
||||
"disables row app toggles while a %s toggle is pending",
|
||||
async (pendingKind) => {
|
||||
installedSkillsMock = [makeInstalledSkill()];
|
||||
if (pendingKind === "single") {
|
||||
toggleSkillAppPending = true;
|
||||
toggleSkillAppVariables = {
|
||||
id: "owner/repo:alpha-skill",
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
};
|
||||
} else {
|
||||
bulkToggleSkillAppPending = true;
|
||||
bulkToggleSkillAppVariables = {
|
||||
ids: ["owner/repo:alpha-skill"],
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
renderPanel();
|
||||
|
||||
const row = screen.getByText("Alpha Skill").closest(".group");
|
||||
const appToggleButtons = Array.from(
|
||||
row!.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).slice(0, 6);
|
||||
|
||||
expect(appToggleButtons).toHaveLength(6);
|
||||
appToggleButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(screen.getByTitle("skills.uninstall")).toBeDisabled();
|
||||
await userEvent.setup().click(appToggleButtons[0]);
|
||||
expect(toggleSkillAppMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("reports check-update availability and clears it on unmount", async () => {
|
||||
installedSkillsMock = [makeInstalledSkill()];
|
||||
const onCheckUpdatesStateChange = vi.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<UnifiedSkillsPanel
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="claude"
|
||||
onCheckUpdatesStateChange={onCheckUpdatesStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCheckUpdatesStateChange).toHaveBeenLastCalledWith({
|
||||
isChecking: false,
|
||||
hasSkills: true,
|
||||
});
|
||||
});
|
||||
expect(screen.queryByText("skills.checkUpdates")).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
expect(onCheckUpdatesStateChange).toHaveBeenLastCalledWith({
|
||||
isChecking: false,
|
||||
hasSkills: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores rapid duplicate check-update ref calls", async () => {
|
||||
installedSkillsMock = [makeInstalledSkill()];
|
||||
let resolveCheck!: (value: { data: never[] }) => void;
|
||||
checkUpdatesMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCheck = resolve;
|
||||
}),
|
||||
);
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="claude"
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ref.current?.checkUpdates();
|
||||
ref.current?.checkUpdates();
|
||||
});
|
||||
expect(checkUpdatesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveCheck({ data: [] });
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks actions but not navigation while checking updates", async () => {
|
||||
installedSkillsMock = [makeInstalledSkill()];
|
||||
checkUpdatesFetching = true;
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
const onInteractionBlockedChange = vi.fn();
|
||||
const onNavigationBlockedChange = vi.fn();
|
||||
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="claude"
|
||||
onInteractionBlockedChange={onInteractionBlockedChange}
|
||||
onNavigationBlockedChange={onNavigationBlockedChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInteractionBlockedChange).toHaveBeenLastCalledWith(true);
|
||||
expect(onNavigationBlockedChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
expect(screen.getByText("Claude:").closest("button")).toBeDisabled();
|
||||
expect(screen.getByTitle("skills.uninstall")).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openImport();
|
||||
});
|
||||
expect(scanUnmanagedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the backup dialog and reports an explicit refresh failure", async () => {
|
||||
refetchSkillBackupsMock.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="claude"
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openRestoreFromBackup();
|
||||
});
|
||||
|
||||
expect(refetchSkillBackupsMock).toHaveBeenCalledWith({
|
||||
throwOnError: true,
|
||||
});
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("common.error", {
|
||||
description: "Error: refresh failed",
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("skills.restoreFromBackup.title"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks writes immediately when an update check starts", async () => {
|
||||
installedSkillsMock = [makeInstalledSkill()];
|
||||
let resolveCheck!: (value: { data: never[] }) => void;
|
||||
checkUpdatesMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCheck = resolve;
|
||||
}),
|
||||
);
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="claude"
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ref.current?.checkUpdates();
|
||||
});
|
||||
expect(checkUpdatesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openImport();
|
||||
});
|
||||
await userEvent.setup().click(screen.getByTitle("skills.uninstall"));
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByText("Claude:").closest("button")!);
|
||||
|
||||
expect(scanUnmanagedMock).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(bulkToggleSkillAppMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveCheck({ data: [] });
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale update entries for uninstalled Skills", async () => {
|
||||
installedSkillsMock = [makeInstalledSkill({ id: "installed-id" })];
|
||||
skillUpdatesMock = [
|
||||
{ id: "removed-id", name: "Removed Skill", remoteHash: "removed" },
|
||||
{ id: "installed-id", name: "Alpha Skill", remoteHash: "current" },
|
||||
];
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getAllByText("skills.updateAvailable")).toHaveLength(1);
|
||||
await userEvent.setup().click(
|
||||
screen.getByRole("button", {
|
||||
name: "skills.updateAll",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSkillMock).toHaveBeenCalledTimes(1);
|
||||
expect(updateSkillMock).toHaveBeenCalledWith("installed-id");
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for an explicit backup refresh before reporting deletion failure", async () => {
|
||||
skillBackupsMock = [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
backupPath: "C:\\backups\\backup-1",
|
||||
createdAt: 1,
|
||||
skill: makeInstalledSkill({ name: "Backup Skill" }),
|
||||
},
|
||||
];
|
||||
deleteSkillBackupMock.mockRejectedValueOnce(undefined);
|
||||
let releaseRefresh: (() => void) | undefined;
|
||||
const refreshPending = new Promise((resolve) => {
|
||||
releaseRefresh = () => resolve({ data: [] });
|
||||
});
|
||||
refetchSkillBackupsMock
|
||||
.mockResolvedValueOnce({ data: skillBackupsMock })
|
||||
.mockReturnValueOnce(refreshPending);
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="claude"
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openRestoreFromBackup();
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "skills.restoreFromBackup.delete",
|
||||
}),
|
||||
);
|
||||
const confirmDialog = screen
|
||||
.getByText("skills.restoreFromBackup.deleteConfirmTitle")
|
||||
.closest<HTMLElement>('[role="dialog"]');
|
||||
expect(confirmDialog).not.toBeNull();
|
||||
await user.click(
|
||||
within(confirmDialog!).getByRole("button", {
|
||||
name: "skills.restoreFromBackup.delete",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteSkillBackupMock).toHaveBeenCalledWith("backup-1");
|
||||
expect(refetchSkillBackupsMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
|
||||
releaseRefresh?.();
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
"skills.restoreFromBackup.deleteFailed",
|
||||
{ description: "undefined" },
|
||||
);
|
||||
});
|
||||
expect(toastSuccessMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByText("skills.restoreFromBackup.deleteConfirmTitle"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not report a completed deletion as failed when refresh rejects", async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
skillBackupsMock = [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
backupPath: "C:\\backups\\backup-1",
|
||||
createdAt: 1,
|
||||
skill: makeInstalledSkill({ name: "Backup Skill" }),
|
||||
},
|
||||
];
|
||||
deleteSkillBackupMock.mockResolvedValueOnce(true);
|
||||
refetchSkillBackupsMock
|
||||
.mockResolvedValueOnce({ data: skillBackupsMock })
|
||||
.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="claude"
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openRestoreFromBackup();
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "skills.restoreFromBackup.delete",
|
||||
}),
|
||||
);
|
||||
const confirmDialog = screen
|
||||
.getByText("skills.restoreFromBackup.deleteConfirmTitle")
|
||||
.closest<HTMLElement>('[role="dialog"]');
|
||||
expect(confirmDialog).not.toBeNull();
|
||||
await user.click(
|
||||
within(confirmDialog!).getByRole("button", {
|
||||
name: "skills.restoreFromBackup.delete",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith(
|
||||
"skills.restoreFromBackup.deleteSuccess",
|
||||
{ closeButton: true },
|
||||
);
|
||||
});
|
||||
expect(refetchSkillBackupsMock).toHaveBeenCalledTimes(2);
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to refresh Skill backups after deletion:",
|
||||
expect.any(Error),
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "@/i18n/locales/en.json";
|
||||
import ja from "@/i18n/locales/ja.json";
|
||||
import zhTW from "@/i18n/locales/zh-TW.json";
|
||||
import zh from "@/i18n/locales/zh.json";
|
||||
|
||||
const requiredPaths = [
|
||||
"common.enableAllForApp",
|
||||
"common.disableAllForApp",
|
||||
"common.bulkToggleFailed",
|
||||
"skills.installedSearchPlaceholder",
|
||||
"skills.installedSearchAriaLabel",
|
||||
"skills.noInstalledSearchResults",
|
||||
"mcp.unifiedPanel.searchPlaceholder",
|
||||
"mcp.unifiedPanel.searchAriaLabel",
|
||||
"mcp.unifiedPanel.noSearchResults",
|
||||
"prompts.searchPlaceholder",
|
||||
"prompts.searchAriaLabel",
|
||||
"prompts.noSearchResults",
|
||||
] as const;
|
||||
|
||||
type Locale = Record<string, unknown>;
|
||||
|
||||
const locales = [
|
||||
["en", en],
|
||||
["ja", ja],
|
||||
["zh", zh],
|
||||
["zh-TW", zhTW],
|
||||
] as const;
|
||||
|
||||
function getTranslation(locale: Locale, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((value, key) => {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
return (value as Record<string, unknown>)[key];
|
||||
}, locale);
|
||||
}
|
||||
|
||||
function interpolationVariables(value: string): string[] {
|
||||
return Array.from(
|
||||
value.matchAll(/\{\{([^}]+)\}\}/g),
|
||||
([, name]) => name,
|
||||
).sort();
|
||||
}
|
||||
|
||||
describe("management list locale coverage", () => {
|
||||
it.each(locales)("defines every management key in %s", (_name, locale) => {
|
||||
const missing = requiredPaths.filter((path) => {
|
||||
const value = getTranslation(locale as Locale, path);
|
||||
return typeof value !== "string" || value.trim().length === 0;
|
||||
});
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(locales.slice(1))(
|
||||
"preserves interpolation variables in %s",
|
||||
(_name, locale) => {
|
||||
for (const path of requiredPaths) {
|
||||
const expected = getTranslation(en as Locale, path) as string;
|
||||
const actual = getTranslation(locale as Locale, path) as string;
|
||||
|
||||
expect(interpolationVariables(actual)).toEqual(
|
||||
interpolationVariables(expected),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,26 +1,6 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mergeImportedSkills } from "@/hooks/useSkills.helpers";
|
||||
import {
|
||||
useImportSkillsFromApps,
|
||||
useInstallSkill,
|
||||
useInstallSkillsFromZip,
|
||||
useUninstallSkill,
|
||||
} from "@/hooks/useSkills";
|
||||
import type { DiscoverableSkill, InstalledSkill } from "@/lib/api/skills";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
importFromApps: vi.fn(),
|
||||
installFromZip: vi.fn(),
|
||||
installUnified: vi.fn(),
|
||||
uninstallUnified: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/skills", () => ({
|
||||
skillsApi: apiMocks,
|
||||
}));
|
||||
import type { InstalledSkill } from "@/lib/api/skills";
|
||||
|
||||
function makeSkill(overrides: Partial<InstalledSkill> = {}): InstalledSkill {
|
||||
return {
|
||||
@@ -41,35 +21,6 @@ function makeSkill(overrides: Partial<InstalledSkill> = {}): InstalledSkill {
|
||||
};
|
||||
}
|
||||
|
||||
function makeDiscoverableSkill(): DiscoverableSkill {
|
||||
return {
|
||||
key: "owner/repo:skill-a",
|
||||
name: "Skill A",
|
||||
description: "Skill A description",
|
||||
directory: "skill-a",
|
||||
repoOwner: "owner",
|
||||
repoName: "repo",
|
||||
repoBranch: "main",
|
||||
};
|
||||
}
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { retry: false },
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createWrapper(queryClient: QueryClient) {
|
||||
return function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Regression coverage for issue #2139: when a user double-clicks the import
|
||||
// button (or the mutation otherwise fires twice with the same payload), the
|
||||
// installed cache must not accumulate duplicate entries for the same skill.
|
||||
@@ -108,236 +59,4 @@ describe("mergeImportedSkills", () => {
|
||||
const merged = mergeImportedSkills(existing, imported);
|
||||
expect(merged.map((s) => s.id).sort()).toEqual(["skill-a", "skill-b"]);
|
||||
});
|
||||
|
||||
it("dedupes repeated IDs within the incoming list and keeps the last value", () => {
|
||||
const first = makeSkill({ name: "First Value" });
|
||||
const last = makeSkill({ name: "Last Value" });
|
||||
|
||||
const merged = mergeImportedSkills(undefined, [first, last]);
|
||||
|
||||
expect(merged).toEqual([last]);
|
||||
});
|
||||
|
||||
it("also removes duplicate IDs already present in stale cache data", () => {
|
||||
const stale = makeSkill({ name: "Stale Value" });
|
||||
const newer = makeSkill({ name: "Newer Value" });
|
||||
const imported = makeSkill({ id: "skill-b", name: "Skill B" });
|
||||
|
||||
const merged = mergeImportedSkills([stale, newer], [imported]);
|
||||
|
||||
expect(merged).toEqual([newer, imported]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Skills install and import mutation hooks", () => {
|
||||
beforeEach(() => {
|
||||
apiMocks.importFromApps.mockReset();
|
||||
apiMocks.installFromZip.mockReset();
|
||||
apiMocks.installUnified.mockReset();
|
||||
apiMocks.uninstallUnified.mockReset();
|
||||
});
|
||||
|
||||
it("merges a successful install by ID without mutating discoverable cache", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const stale = makeSkill({ name: "Stale Skill" });
|
||||
const unrelated = makeSkill({ id: "skill-b", name: "Skill B" });
|
||||
const installed = makeSkill({ name: "Fresh Skill" });
|
||||
const discoverable = [makeDiscoverableSkill()];
|
||||
queryClient.setQueryData(["skills", "installed"], [stale, unrelated]);
|
||||
queryClient.setQueryData(["skills", "discoverable"], discoverable);
|
||||
apiMocks.installUnified.mockResolvedValueOnce(installed);
|
||||
const { result } = renderHook(() => useInstallSkill(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
skill: makeDiscoverableSkill(),
|
||||
currentApp: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<InstalledSkill[]>(["skills", "installed"]),
|
||||
).toEqual([installed, unrelated]);
|
||||
expect(queryClient.getQueryData(["skills", "discoverable"])).toBe(
|
||||
discoverable,
|
||||
);
|
||||
});
|
||||
|
||||
it("merges ZIP results without duplicate IDs", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const stale = makeSkill({ name: "Stale Skill" });
|
||||
const first = makeSkill({ name: "First ZIP Value" });
|
||||
const last = makeSkill({ name: "Last ZIP Value" });
|
||||
const second = makeSkill({ id: "skill-b", name: "Skill B" });
|
||||
queryClient.setQueryData(["skills", "installed"], [stale]);
|
||||
apiMocks.installFromZip.mockResolvedValueOnce([first, last, second]);
|
||||
const { result } = renderHook(() => useInstallSkillsFromZip(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
filePath: "C:\\skills.zip",
|
||||
currentApp: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<InstalledSkill[]>(["skills", "installed"]),
|
||||
).toEqual([last, second]);
|
||||
});
|
||||
|
||||
it("merges imported results and refreshes every affected collection", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const stale = makeSkill({ name: "Stale Skill" });
|
||||
const imported = makeSkill({ name: "Imported Skill" });
|
||||
queryClient.setQueryData(["skills", "installed"], [stale]);
|
||||
apiMocks.importFromApps.mockResolvedValueOnce([imported, imported]);
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
const { result } = renderHook(() => useImportSkillsFromApps(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync([]);
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<InstalledSkill[]>(["skills", "installed"]),
|
||||
).toEqual([imported]);
|
||||
for (const queryKey of [
|
||||
["skills", "installed"],
|
||||
["skills", "unmanaged"],
|
||||
["skills", "repos"],
|
||||
["skills", "discoverable"],
|
||||
]) {
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a rejected install pending until installed and unmanaged refresh", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
const queryClient = createQueryClient();
|
||||
apiMocks.installUnified.mockRejectedValueOnce(new Error("sync failed"));
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useInstallSkill(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<InstalledSkill>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync({
|
||||
skill: makeDiscoverableSkill(),
|
||||
currentApp: "claude",
|
||||
});
|
||||
void mutation.catch(() => undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "installed"],
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "unmanaged"],
|
||||
});
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await expect(mutation).rejects.toThrow("sync failed");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("refreshes installed and unmanaged when ZIP install rejects", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
apiMocks.installFromZip.mockRejectedValueOnce(new Error("sync failed"));
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
const { result } = renderHook(() => useInstallSkillsFromZip(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
filePath: "C:\\skills.zip",
|
||||
currentApp: "claude",
|
||||
}),
|
||||
).rejects.toThrow("sync failed");
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "installed"],
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "unmanaged"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a rejected import pending until all affected caches refresh", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
const queryClient = createQueryClient();
|
||||
apiMocks.importFromApps.mockRejectedValueOnce(new Error("import failed"));
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useImportSkillsFromApps(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<InstalledSkill[]>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync([]);
|
||||
void mutation.catch(() => undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(4));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
for (const queryKey of [
|
||||
["skills", "installed"],
|
||||
["skills", "unmanaged"],
|
||||
["skills", "repos"],
|
||||
["skills", "discoverable"],
|
||||
]) {
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey });
|
||||
}
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await expect(mutation).rejects.toThrow("import failed");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("does not rewrite discoverable cache when uninstall succeeds", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const discoverable = [makeDiscoverableSkill()];
|
||||
queryClient.setQueryData(["skills", "installed"], [makeSkill()]);
|
||||
queryClient.setQueryData(["skills", "discoverable"], discoverable);
|
||||
apiMocks.uninstallUnified.mockResolvedValueOnce({ backupPath: undefined });
|
||||
const { result } = renderHook(() => useUninstallSkill(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync("skill-a");
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<InstalledSkill[]>(["skills", "installed"]),
|
||||
).toEqual([]);
|
||||
expect(queryClient.getQueryData(["skills", "discoverable"])).toBe(
|
||||
discoverable,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
useBulkToggleMcpApp,
|
||||
useDeleteMcpServer,
|
||||
useImportMcpFromApps,
|
||||
useToggleMcpApp,
|
||||
useUpsertMcpServer,
|
||||
} from "@/hooks/useMcp";
|
||||
import type { McpServer } from "@/types";
|
||||
|
||||
const toggleAppMock = vi.hoisted(() => vi.fn());
|
||||
const upsertServerMock = vi.hoisted(() => vi.fn());
|
||||
const deleteServerMock = vi.hoisted(() => vi.fn());
|
||||
const importFromAppsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/mcp", () => ({
|
||||
mcpApi: {
|
||||
toggleApp: toggleAppMock,
|
||||
upsertUnifiedServer: upsertServerMock,
|
||||
deleteUnifiedServer: deleteServerMock,
|
||||
importFromApps: importFromAppsMock,
|
||||
},
|
||||
}));
|
||||
|
||||
function createWrapper(queryClient: QueryClient) {
|
||||
return function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("MCP management mutation hooks", () => {
|
||||
beforeEach(() => {
|
||||
toggleAppMock.mockReset();
|
||||
upsertServerMock.mockReset();
|
||||
deleteServerMock.mockReset();
|
||||
importFromAppsMock.mockReset();
|
||||
});
|
||||
|
||||
it("runs bulk writes serially and invalidates the list once", async () => {
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const firstPending = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
toggleAppMock.mockImplementation(async (serverId: string) => {
|
||||
if (serverId === "alpha") await firstPending;
|
||||
});
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useBulkToggleMcpApp(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync({
|
||||
serverIds: ["alpha", "beta"],
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggleAppMock).toHaveBeenCalledTimes(1));
|
||||
releaseFirst?.();
|
||||
await waitFor(() => expect(toggleAppMock).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await mutation;
|
||||
});
|
||||
|
||||
expect(toggleAppMock.mock.calls).toEqual([
|
||||
["alpha", "claude", true],
|
||||
["beta", "claude", true],
|
||||
]);
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["mcp", "all"] });
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("refreshes the list when a single live-config write fails", async () => {
|
||||
toggleAppMock.mockRejectedValueOnce(new Error("write failed"));
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
const { result } = renderHook(() => useToggleMcpApp(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
serverId: "alpha",
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
}),
|
||||
).rejects.toThrow("write failed");
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["mcp", "all"] });
|
||||
});
|
||||
|
||||
it("keeps a single toggle pending until the refreshed list is available", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
toggleAppMock.mockResolvedValueOnce(undefined);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useToggleMcpApp(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync({
|
||||
serverId: "alpha",
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await mutation;
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("refreshes and stays pending when an upsert fails after persistence", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
upsertServerMock.mockRejectedValueOnce(new Error("live sync failed"));
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useUpsertMcpServer(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync({ id: "alpha" } as McpServer);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await expect(mutation).rejects.toThrow("live sync failed");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("refreshes and stays pending when deletion fails after persistence", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
deleteServerMock.mockRejectedValueOnce(new Error("live cleanup failed"));
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useDeleteMcpServer(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync("alpha");
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await expect(mutation).rejects.toThrow("live cleanup failed");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("keeps an import pending until the complete server list is refreshed", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
importFromAppsMock.mockResolvedValueOnce(2);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useImportMcpFromApps(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await mutation;
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
});
|
||||
@@ -1,367 +0,0 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { usePromptActions } from "@/hooks/usePromptActions";
|
||||
import type { AppId, Prompt } from "@/lib/api";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getPrompts: vi.fn(),
|
||||
getCurrentFileContent: vi.fn(),
|
||||
enablePrompt: vi.fn(),
|
||||
upsertPrompt: vi.fn(),
|
||||
deletePrompt: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
promptsApi: {
|
||||
getPrompts: mocks.getPrompts,
|
||||
getCurrentFileContent: mocks.getCurrentFileContent,
|
||||
enablePrompt: mocks.enablePrompt,
|
||||
upsertPrompt: mocks.upsertPrompt,
|
||||
deletePrompt: mocks.deletePrompt,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
},
|
||||
}));
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: Deferred<T>["resolve"];
|
||||
let reject!: Deferred<T>["reject"];
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makePrompts(id: string, name: string): Record<string, Prompt> {
|
||||
return {
|
||||
[id]: {
|
||||
id,
|
||||
name,
|
||||
content: `${name} content`,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderPromptActions(initialAppId: AppId) {
|
||||
return renderHook(({ appId }: { appId: AppId }) => usePromptActions(appId), {
|
||||
initialProps: { appId: initialAppId },
|
||||
});
|
||||
}
|
||||
|
||||
describe("usePromptActions reload concurrency", () => {
|
||||
beforeEach(() => {
|
||||
mocks.getPrompts.mockReset();
|
||||
mocks.getCurrentFileContent.mockReset();
|
||||
mocks.getCurrentFileContent.mockResolvedValue(null);
|
||||
mocks.enablePrompt.mockReset();
|
||||
mocks.enablePrompt.mockResolvedValue(undefined);
|
||||
mocks.upsertPrompt.mockReset();
|
||||
mocks.upsertPrompt.mockResolvedValue(undefined);
|
||||
mocks.deletePrompt.mockReset();
|
||||
mocks.deletePrompt.mockResolvedValue(undefined);
|
||||
mocks.toastError.mockReset();
|
||||
mocks.toastSuccess.mockReset();
|
||||
});
|
||||
|
||||
it("does not let an older app request overwrite the current app", async () => {
|
||||
const claudeRequest = createDeferred<Record<string, Prompt>>();
|
||||
const codexRequest = createDeferred<Record<string, Prompt>>();
|
||||
mocks.getPrompts.mockImplementation((appId: AppId) =>
|
||||
appId === "claude" ? claudeRequest.promise : codexRequest.promise,
|
||||
);
|
||||
mocks.getCurrentFileContent.mockImplementation(
|
||||
async (appId: AppId) => `${appId} live content`,
|
||||
);
|
||||
|
||||
const { result, rerender } = renderPromptActions("claude");
|
||||
let claudeReload!: Promise<boolean>;
|
||||
act(() => {
|
||||
claudeReload = result.current.reload();
|
||||
});
|
||||
|
||||
rerender({ appId: "codex" });
|
||||
let codexReload!: Promise<boolean>;
|
||||
act(() => {
|
||||
codexReload = result.current.reload();
|
||||
});
|
||||
|
||||
codexRequest.resolve(makePrompts("codex-prompt", "Codex Prompt"));
|
||||
await act(async () => {
|
||||
await codexReload;
|
||||
});
|
||||
|
||||
expect(result.current.prompts).toEqual(
|
||||
makePrompts("codex-prompt", "Codex Prompt"),
|
||||
);
|
||||
expect(result.current.currentFileContent).toBe("codex live content");
|
||||
|
||||
claudeRequest.resolve(makePrompts("claude-prompt", "Claude Prompt"));
|
||||
await act(async () => {
|
||||
await claudeReload;
|
||||
});
|
||||
|
||||
expect(result.current.prompts).toEqual(
|
||||
makePrompts("codex-prompt", "Codex Prompt"),
|
||||
);
|
||||
expect(result.current.currentFileContent).toBe("codex live content");
|
||||
expect(mocks.getCurrentFileContent).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.getCurrentFileContent).toHaveBeenCalledWith("codex");
|
||||
});
|
||||
|
||||
it("keeps the newer result when same-app reloads finish out of order", async () => {
|
||||
const olderRequest = createDeferred<Record<string, Prompt>>();
|
||||
const newerRequest = createDeferred<Record<string, Prompt>>();
|
||||
mocks.getPrompts
|
||||
.mockReturnValueOnce(olderRequest.promise)
|
||||
.mockReturnValueOnce(newerRequest.promise);
|
||||
mocks.getCurrentFileContent.mockResolvedValue("latest live content");
|
||||
|
||||
const { result } = renderPromptActions("claude");
|
||||
let olderReload!: Promise<boolean>;
|
||||
let newerReload!: Promise<boolean>;
|
||||
act(() => {
|
||||
olderReload = result.current.reload();
|
||||
newerReload = result.current.reload();
|
||||
});
|
||||
|
||||
newerRequest.resolve(makePrompts("newer-prompt", "Newer Prompt"));
|
||||
await act(async () => {
|
||||
await newerReload;
|
||||
});
|
||||
|
||||
olderRequest.resolve(makePrompts("older-prompt", "Older Prompt"));
|
||||
await act(async () => {
|
||||
await olderReload;
|
||||
});
|
||||
|
||||
expect(result.current.prompts).toEqual(
|
||||
makePrompts("newer-prompt", "Newer Prompt"),
|
||||
);
|
||||
expect(result.current.currentFileContent).toBe("latest live content");
|
||||
expect(mocks.getCurrentFileContent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores an older request error while the current app is loading", async () => {
|
||||
const claudeRequest = createDeferred<Record<string, Prompt>>();
|
||||
const codexRequest = createDeferred<Record<string, Prompt>>();
|
||||
mocks.getPrompts.mockImplementation((appId: AppId) =>
|
||||
appId === "claude" ? claudeRequest.promise : codexRequest.promise,
|
||||
);
|
||||
|
||||
const { result, rerender } = renderPromptActions("claude");
|
||||
let claudeReload!: Promise<boolean>;
|
||||
act(() => {
|
||||
claudeReload = result.current.reload();
|
||||
});
|
||||
|
||||
rerender({ appId: "codex" });
|
||||
let codexReload!: Promise<boolean>;
|
||||
act(() => {
|
||||
codexReload = result.current.reload();
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(true));
|
||||
|
||||
claudeRequest.reject(new Error("stale Claude failure"));
|
||||
await act(async () => {
|
||||
await claudeReload;
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(mocks.toastError).not.toHaveBeenCalled();
|
||||
|
||||
codexRequest.resolve(makePrompts("codex-prompt", "Codex Prompt"));
|
||||
await act(async () => {
|
||||
await codexReload;
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.prompts).toEqual(
|
||||
makePrompts("codex-prompt", "Codex Prompt"),
|
||||
);
|
||||
expect(mocks.toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show an error when a pending reload fails after unmount", async () => {
|
||||
const request = createDeferred<Record<string, Prompt>>();
|
||||
mocks.getPrompts.mockReturnValue(request.promise);
|
||||
|
||||
const { result, unmount } = renderPromptActions("claude");
|
||||
let reload!: Promise<boolean>;
|
||||
act(() => {
|
||||
reload = result.current.reload();
|
||||
});
|
||||
|
||||
unmount();
|
||||
request.reject(new Error("failure after unmount"));
|
||||
await act(async () => {
|
||||
await reload;
|
||||
});
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the previous app prompts when the new app reload fails", async () => {
|
||||
const claudePrompts = makePrompts("claude-prompt", "Claude Prompt");
|
||||
mocks.getPrompts
|
||||
.mockResolvedValueOnce(claudePrompts)
|
||||
.mockRejectedValueOnce(new Error("Codex load failed"));
|
||||
mocks.getCurrentFileContent.mockResolvedValueOnce("claude live content");
|
||||
|
||||
const { result, rerender } = renderPromptActions("claude");
|
||||
await act(async () => {
|
||||
expect(await result.current.reload()).toBe(true);
|
||||
});
|
||||
expect(result.current.prompts).toEqual(claudePrompts);
|
||||
expect(result.current.currentFileContent).toBe("claude live content");
|
||||
|
||||
rerender({ appId: "codex" });
|
||||
expect(result.current.prompts).toEqual({});
|
||||
expect(result.current.currentFileContent).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
expect(await result.current.reload()).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.prompts).toEqual({});
|
||||
expect(result.current.currentFileContent).toBeNull();
|
||||
expect(mocks.toastError).toHaveBeenCalledWith("prompts.loadFailed");
|
||||
});
|
||||
|
||||
it("does not roll back the current app when an older app toggle fails", async () => {
|
||||
const claudePrompts = makePrompts("claude-prompt", "Claude Prompt");
|
||||
const codexPrompts = makePrompts("codex-prompt", "Codex Prompt");
|
||||
const enableRequest = createDeferred<void>();
|
||||
mocks.getPrompts.mockImplementation(async (appId: AppId) =>
|
||||
appId === "claude" ? claudePrompts : codexPrompts,
|
||||
);
|
||||
mocks.enablePrompt.mockReturnValueOnce(enableRequest.promise);
|
||||
|
||||
const { result, rerender } = renderPromptActions("claude");
|
||||
await act(async () => {
|
||||
expect(await result.current.reload()).toBe(true);
|
||||
});
|
||||
|
||||
let togglePromise!: Promise<boolean>;
|
||||
act(() => {
|
||||
togglePromise = result.current.toggleEnabled("claude-prompt", true);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mocks.enablePrompt).toHaveBeenCalledWith(
|
||||
"claude",
|
||||
"claude-prompt",
|
||||
);
|
||||
});
|
||||
|
||||
rerender({ appId: "codex" });
|
||||
await act(async () => {
|
||||
expect(await result.current.reload()).toBe(true);
|
||||
});
|
||||
expect(result.current.prompts).toEqual(codexPrompts);
|
||||
|
||||
enableRequest.reject(new Error("stale Claude toggle failed"));
|
||||
await act(async () => {
|
||||
await expect(togglePromise).rejects.toThrow("stale Claude toggle failed");
|
||||
});
|
||||
|
||||
expect(result.current.prompts).toEqual(codexPrompts);
|
||||
expect(result.current.currentFileContent).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a saved prompt locally when the follow-up reload fails", async () => {
|
||||
const initialPrompts = makePrompts("existing", "Existing Prompt");
|
||||
const savedPrompt: Prompt = {
|
||||
id: "saved",
|
||||
name: "Saved Prompt",
|
||||
content: "Saved content",
|
||||
enabled: false,
|
||||
};
|
||||
mocks.getPrompts
|
||||
.mockResolvedValueOnce(initialPrompts)
|
||||
.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
|
||||
const { result } = renderPromptActions("claude");
|
||||
await act(async () => {
|
||||
expect(await result.current.reload()).toBe(true);
|
||||
expect(await result.current.savePrompt("saved", savedPrompt)).toBe(false);
|
||||
});
|
||||
|
||||
expect(mocks.upsertPrompt).toHaveBeenCalledWith(
|
||||
"claude",
|
||||
"saved",
|
||||
savedPrompt,
|
||||
);
|
||||
expect(result.current.prompts).toEqual({
|
||||
...initialPrompts,
|
||||
saved: savedPrompt,
|
||||
});
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith("prompts.saveSuccess", {
|
||||
closeButton: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a deleted prompt removed when the follow-up reload fails", async () => {
|
||||
const initialPrompts = {
|
||||
...makePrompts("keep", "Keep Prompt"),
|
||||
...makePrompts("remove", "Remove Prompt"),
|
||||
};
|
||||
mocks.getPrompts
|
||||
.mockResolvedValueOnce(initialPrompts)
|
||||
.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
|
||||
const { result } = renderPromptActions("claude");
|
||||
await act(async () => {
|
||||
expect(await result.current.reload()).toBe(true);
|
||||
expect(await result.current.deletePrompt("remove")).toBe(false);
|
||||
});
|
||||
|
||||
expect(mocks.deletePrompt).toHaveBeenCalledWith("claude", "remove");
|
||||
expect(result.current.prompts).toEqual(makePrompts("keep", "Keep Prompt"));
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith("prompts.deleteSuccess", {
|
||||
closeButton: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an optimistic toggle when the follow-up reload fails", async () => {
|
||||
const initialPrompts = makePrompts("toggle", "Toggle Prompt");
|
||||
mocks.getPrompts
|
||||
.mockResolvedValueOnce(initialPrompts)
|
||||
.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
|
||||
const { result } = renderPromptActions("claude");
|
||||
await act(async () => {
|
||||
expect(await result.current.reload()).toBe(true);
|
||||
});
|
||||
await act(async () => {
|
||||
expect(await result.current.toggleEnabled("toggle", true)).toBe(false);
|
||||
});
|
||||
|
||||
expect(mocks.enablePrompt).toHaveBeenCalledWith("claude", "toggle");
|
||||
expect(result.current.prompts.toggle.enabled).toBe(true);
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith("prompts.enableSuccess", {
|
||||
closeButton: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,366 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
useBulkToggleSkillApp,
|
||||
useDeleteSkillBackup,
|
||||
useRestoreSkillBackup,
|
||||
useToggleSkillApp,
|
||||
useUninstallSkill,
|
||||
useUpdateSkill,
|
||||
} from "@/hooks/useSkills";
|
||||
import type { SkillBackupEntry, SkillUpdateInfo } from "@/lib/api/skills";
|
||||
|
||||
const toggleAppMock = vi.hoisted(() => vi.fn());
|
||||
const restoreBackupMock = vi.hoisted(() => vi.fn());
|
||||
const uninstallMock = vi.hoisted(() => vi.fn());
|
||||
const deleteBackupMock = vi.hoisted(() => vi.fn());
|
||||
const updateSkillMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/skills", () => ({
|
||||
skillsApi: {
|
||||
toggleApp: toggleAppMock,
|
||||
restoreBackup: restoreBackupMock,
|
||||
uninstallUnified: uninstallMock,
|
||||
deleteBackup: deleteBackupMock,
|
||||
updateSkill: updateSkillMock,
|
||||
},
|
||||
}));
|
||||
|
||||
function createWrapper(queryClient: QueryClient) {
|
||||
return function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("Skills management mutation hooks", () => {
|
||||
beforeEach(() => {
|
||||
toggleAppMock.mockReset();
|
||||
restoreBackupMock.mockReset();
|
||||
uninstallMock.mockReset();
|
||||
deleteBackupMock.mockReset();
|
||||
updateSkillMock.mockReset();
|
||||
});
|
||||
|
||||
it("stays pending until the refreshed skill list is available", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
toggleAppMock.mockResolvedValue(undefined);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useBulkToggleSkillApp(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync({
|
||||
ids: ["alpha", "beta"],
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggleAppMock).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await mutation;
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "installed"],
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("keeps a single toggle pending until the refreshed list is available", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
toggleAppMock.mockResolvedValueOnce(undefined);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useToggleSkillApp(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync({
|
||||
id: "alpha",
|
||||
app: "claude",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await mutation;
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("keeps backup restore pending until installed skills and backups refresh", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
restoreBackupMock.mockResolvedValueOnce(undefined);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useRestoreSkillBackup(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync({
|
||||
backupId: "backup-1",
|
||||
currentApp: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(2));
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "installed"],
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "backups"],
|
||||
});
|
||||
expect(result.current.isPending).toBe(true);
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await mutation;
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("also refreshes installed skills and backups when restore rejects", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
restoreBackupMock.mockRejectedValueOnce(new Error("sync failed"));
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useRestoreSkillBackup(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync({
|
||||
backupId: "backup-1",
|
||||
currentApp: "claude",
|
||||
});
|
||||
void mutation.catch(() => undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "installed"],
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "backups"],
|
||||
});
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await expect(mutation).rejects.toThrow("sync failed");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("removes an uninstalled Skill from cached update results", async () => {
|
||||
uninstallMock.mockResolvedValueOnce({ backupPath: null });
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData<SkillUpdateInfo[]>(
|
||||
["skills", "updates"],
|
||||
[
|
||||
{ id: "alpha", name: "Alpha", remoteHash: "alpha-remote" },
|
||||
{ id: "beta", name: "Beta", remoteHash: "beta-remote" },
|
||||
],
|
||||
);
|
||||
const { result } = renderHook(() => useUninstallSkill(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync("alpha");
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<SkillUpdateInfo[]>(["skills", "updates"]),
|
||||
).toEqual([{ id: "beta", name: "Beta", remoteHash: "beta-remote" }]);
|
||||
});
|
||||
|
||||
it("keeps a rejected uninstall pending until backups and unmanaged Skills refresh", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
uninstallMock.mockRejectedValueOnce(new Error("remove failed"));
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useUninstallSkill(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync("alpha");
|
||||
void mutation.catch(() => undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "backups"],
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "unmanaged"],
|
||||
});
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await expect(mutation).rejects.toThrow("remove failed");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("keeps a rejected update pending until backups refresh", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
updateSkillMock.mockRejectedValueOnce(new Error("replace failed"));
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useUpdateSkill(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync("alpha");
|
||||
void mutation.catch(() => undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "backups"],
|
||||
});
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await expect(mutation).rejects.toThrow("replace failed");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("keeps a rejected backup deletion pending until backups refresh", async () => {
|
||||
let releaseInvalidation: (() => void) | undefined;
|
||||
const invalidationPending = new Promise<void>((resolve) => {
|
||||
releaseInvalidation = resolve;
|
||||
});
|
||||
deleteBackupMock.mockRejectedValueOnce(new Error("partial delete"));
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi
|
||||
.spyOn(queryClient, "invalidateQueries")
|
||||
.mockImplementation(() => invalidationPending);
|
||||
const { result } = renderHook(() => useDeleteSkillBackup(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutateAsync("backup-1");
|
||||
void mutation.catch(() => undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["skills", "backups"],
|
||||
});
|
||||
|
||||
releaseInvalidation?.();
|
||||
await act(async () => {
|
||||
await expect(mutation).rejects.toThrow("partial delete");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
});
|
||||
|
||||
it("removes a successfully deleted backup before a refresh failure", async () => {
|
||||
deleteBackupMock.mockResolvedValueOnce(true);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData<SkillBackupEntry[]>(
|
||||
["skills", "backups"],
|
||||
[
|
||||
{ backupId: "backup-1" } as SkillBackupEntry,
|
||||
{ backupId: "backup-2" } as SkillBackupEntry,
|
||||
],
|
||||
);
|
||||
vi.spyOn(queryClient, "invalidateQueries").mockRejectedValueOnce(
|
||||
new Error("refresh failed"),
|
||||
);
|
||||
const { result } = renderHook(() => useDeleteSkillBackup(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync("backup-1")).rejects.toThrow(
|
||||
"refresh failed",
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<SkillBackupEntry[]>(["skills", "backups"]),
|
||||
).toEqual([{ backupId: "backup-2" }]);
|
||||
});
|
||||
});
|
||||
@@ -13,10 +13,6 @@ import { emitTauriEvent } from "../msw/tauriMocks";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
const toastErrorMock = vi.fn();
|
||||
const skillsPanelMocks = vi.hoisted(() => ({
|
||||
checkUpdates: vi.fn(),
|
||||
openDiscovery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
@@ -132,32 +128,6 @@ vi.mock("@/components/AppSwitcher", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/skills/UnifiedSkillsPanel", async () => {
|
||||
const React = await import("react");
|
||||
const MockUnifiedSkillsPanel = React.forwardRef(
|
||||
({ onCheckUpdatesStateChange }: any, ref) => {
|
||||
React.useEffect(() => {
|
||||
onCheckUpdatesStateChange?.({ isChecking: false, hasSkills: true });
|
||||
return () =>
|
||||
onCheckUpdatesStateChange?.({
|
||||
isChecking: false,
|
||||
hasSkills: false,
|
||||
});
|
||||
}, [onCheckUpdatesStateChange]);
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openDiscovery: skillsPanelMocks.openDiscovery,
|
||||
openImport: vi.fn(),
|
||||
openInstallFromZip: vi.fn(),
|
||||
openRestoreFromBackup: vi.fn(),
|
||||
checkUpdates: skillsPanelMocks.checkUpdates,
|
||||
}));
|
||||
return <div data-testid="unified-skills-panel" />;
|
||||
},
|
||||
);
|
||||
MockUnifiedSkillsPanel.displayName = "MockUnifiedSkillsPanel";
|
||||
return { default: MockUnifiedSkillsPanel };
|
||||
});
|
||||
|
||||
vi.mock("@/components/UpdateBadge", () => ({
|
||||
UpdateBadge: ({ onClick }: any) => (
|
||||
<button onClick={onClick}>update-badge</button>
|
||||
@@ -191,9 +161,6 @@ describe("App integration with MSW", () => {
|
||||
resetProviderState();
|
||||
toastSuccessMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
skillsPanelMocks.checkUpdates.mockReset();
|
||||
skillsPanelMocks.openDiscovery.mockReset();
|
||||
localStorage.removeItem("cc-switch-last-view");
|
||||
});
|
||||
|
||||
it("covers basic provider flows via real hooks", async () => {
|
||||
@@ -385,39 +352,4 @@ describe("App integration with MSW", () => {
|
||||
|
||||
liveIdsSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("hosts the Skills check-update action in the App toolbar", async () => {
|
||||
localStorage.setItem("cc-switch-last-view", "skills");
|
||||
const { default: App } = await import("@/App");
|
||||
renderApp(App);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("unified-skills-panel"),
|
||||
).toBeInTheDocument();
|
||||
const checkUpdatesButton = await screen.findByRole("button", {
|
||||
name: "skills.checkUpdates",
|
||||
});
|
||||
await waitFor(() => expect(checkUpdatesButton).toBeEnabled());
|
||||
|
||||
fireEvent.click(checkUpdatesButton);
|
||||
expect(skillsPanelMocks.checkUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes the Skills discover toolbar action through the panel guard", async () => {
|
||||
localStorage.setItem("cc-switch-last-view", "skills");
|
||||
const { default: App } = await import("@/App");
|
||||
renderApp(App);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("unified-skills-panel"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "skills.discover",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(skillsPanelMocks.openDiscovery).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("unified-skills-panel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runSequentialBulkAction } from "@/lib/utils/sequentialBulkAction";
|
||||
|
||||
describe("runSequentialBulkAction", () => {
|
||||
it("waits for each action before starting the next one", async () => {
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstPending = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
const action = vi.fn(async (item: number) => {
|
||||
if (item === 1) await firstPending;
|
||||
});
|
||||
|
||||
const resultPromise = runSequentialBulkAction([1, 2, 3], action);
|
||||
await Promise.resolve();
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseFirst?.();
|
||||
const result = await resultPromise;
|
||||
expect(action.mock.calls.map(([item]) => item)).toEqual([1, 2, 3]);
|
||||
expect(result).toEqual({ succeeded: [1, 2, 3], failed: [] });
|
||||
});
|
||||
|
||||
it("continues after failures and returns their original items", async () => {
|
||||
const failure = new Error("failed");
|
||||
const action = vi.fn(async (item: string) => {
|
||||
if (item === "beta") throw failure;
|
||||
});
|
||||
|
||||
const result = await runSequentialBulkAction(
|
||||
["alpha", "beta", "gamma"],
|
||||
action,
|
||||
);
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(3);
|
||||
expect(result.succeeded).toEqual(["alpha", "gamma"]);
|
||||
expect(result.failed).toEqual([{ item: "beta", error: failure }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user