diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 391587ef8..703fc5f34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,9 +16,12 @@ jobs: release: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: include: - os: windows-2022 + - os: windows-11-arm + arch: arm64 - os: ubuntu-22.04 - os: ubuntu-22.04-arm arch: arm64 @@ -36,6 +39,11 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable + - name: Add Windows ARM64 target + if: runner.os == 'Windows' && matrix.arch == 'arm64' + shell: pwsh + run: rustup target add aarch64-pc-windows-msvc + - name: Add macOS targets if: runner.os == 'macOS' run: | @@ -74,23 +82,50 @@ jobs: || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - name: Setup pnpm + 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 prepare pnpm@10.12.3 --activate + node --version + pnpm --version + - name: Get pnpm store directory + if: runner.os != 'Windows' || matrix.arch != 'arm64' id: pnpm-store shell: bash run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Setup pnpm cache + if: runner.os != 'Windows' || matrix.arch != 'arm64' uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store- + - name: Setup LLVM for Windows ARM64 + if: runner.os == 'Windows' && matrix.arch == 'arm64' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $llvmRoot = 'C:\Program Files\LLVM' + if (-not (Test-Path $llvmRoot)) { + throw "LLVM not found at $llvmRoot" + } + $llvmBin = Join-Path $llvmRoot 'bin' + "LIBCLANG_PATH=$llvmBin" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "CLANG_PATH=$(Join-Path $llvmBin 'clang.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + $llvmBin | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + - name: Install frontend deps run: pnpm install --frozen-lockfile @@ -223,7 +258,16 @@ jobs: - name: Build Tauri App (Windows) if: runner.os == 'Windows' - run: pnpm tauri build + shell: pwsh + env: + WINDOWS_RELEASE_ARCH: ${{ matrix.arch || 'x86_64' }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:WINDOWS_RELEASE_ARCH -eq 'arm64') { + pnpm tauri build --target aarch64-pc-windows-msvc --bundles msi + } else { + pnpm tauri build + } - name: Build Tauri App (Linux) if: runner.os == 'Linux' @@ -394,50 +438,75 @@ jobs: - name: Prepare Windows Assets if: runner.os == 'Windows' shell: pwsh + env: + WINDOWS_RELEASE_ARCH: ${{ matrix.arch || 'x86_64' }} run: | $ErrorActionPreference = 'Stop' New-Item -ItemType Directory -Force -Path release-assets | Out-Null $VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0 + $isArm64 = $env:WINDOWS_RELEASE_ARCH -eq 'arm64' + $targetRoot = if ($isArm64) { 'src-tauri/target/aarch64-pc-windows-msvc/release' } else { 'src-tauri/target/release' } + $assetSuffix = if ($isArm64) { '-arm64' } else { '' } + # 仅打包 MSI 安装器 + .sig(用于 Updater) - $msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1 + $msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle/msi') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $msi) { # 兜底:全局搜索 .msi - $msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1 + $msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1 } if ($null -ne $msi) { - $dest = "CC-Switch-$VERSION-Windows.msi" + $dest = "CC-Switch-$VERSION-Windows$assetSuffix.msi" Copy-Item $msi.FullName (Join-Path release-assets $dest) Write-Host "Installer copied: $dest" $sigPath = "$($msi.FullName).sig" if (Test-Path $sigPath) { Copy-Item $sigPath (Join-Path release-assets ("$dest.sig")) Write-Host "Signature copied: $dest.sig" + } elseif ($isArm64) { + throw "Signature not found for $($msi.Name)" } else { Write-Warning "Signature not found for $($msi.Name)" } + } elseif ($isArm64) { + throw 'No Windows ARM64 MSI installer found' } else { Write-Warning 'No Windows MSI installer found' } + # 绿色版(portable):仅可执行文件打 zip(不参与 Updater) - $exeCandidates = @( - 'src-tauri/target/release/cc-switch.exe', - 'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe' - ) + $exeCandidates = if ($isArm64) { + @('src-tauri/target/aarch64-pc-windows-msvc/release/cc-switch.exe') + } else { + @( + 'src-tauri/target/release/cc-switch.exe', + 'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe' + ) + } $exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 if ($null -ne $exePath) { $portableDir = 'release-assets/CC-Switch-Portable' New-Item -ItemType Directory -Force -Path $portableDir | Out-Null Copy-Item $exePath $portableDir $portableIniPath = Join-Path $portableDir 'portable.ini' - $portableContent = @( - '# CC Switch portable build marker', - 'portable=true' - ) + $portableContent = if ($isArm64) { + @( + '# CC Switch portable ARM64 build marker', + 'portable=true', + 'arch=arm64' + ) + } else { + @( + '# CC Switch portable build marker', + 'portable=true' + ) + } $portableContent | Set-Content -Path $portableIniPath -Encoding UTF8 - $portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip" + $portableZip = "release-assets/CC-Switch-$VERSION-Windows$assetSuffix-Portable.zip" Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force Remove-Item -Recurse -Force $portableDir - Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip" + Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows$assetSuffix-Portable.zip" + } elseif ($isArm64) { + throw 'Portable ARM64 exe not found' } else { Write-Warning 'Portable exe not found' } @@ -550,7 +619,8 @@ jobs: ### 下载 - **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.dmg`(推荐)或 `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用) - - **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版) + - **Windows (x86_64)**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版) + - **Windows (ARM64)**: `CC-Switch-${{ github.ref_name }}-Windows-arm64.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-arm64-Portable.zip`(绿色版) - **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm` - **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm` @@ -594,7 +664,8 @@ jobs: base_url="https://github.com/$REPO/releases/download/$TAG" # 初始化空平台映射 mac_url=""; mac_sig="" - win_url=""; win_sig="" + win_x64_url=""; win_x64_sig="" + win_arm64_url=""; win_arm64_sig="" linux_x64_url=""; linux_x64_sig="" linux_arm64_url=""; linux_arm64_sig="" shopt -s nullglob @@ -607,12 +678,14 @@ jobs: *.tar.gz) # 视为 macOS updater artifact mac_url="$url"; mac_sig="$sig_content";; + *-Windows-arm64.msi) + win_arm64_url="$url"; win_arm64_sig="$sig_content";; + *-Windows.msi) + win_x64_url="$url"; win_x64_sig="$sig_content";; *-Linux-arm64.AppImage|*-Linux-arm64.appimage) linux_arm64_url="$url"; linux_arm64_sig="$sig_content";; *-Linux-x86_64.AppImage|*-Linux-x86_64.appimage) linux_x64_url="$url"; linux_x64_sig="$sig_content";; - *.msi|*.exe) - win_url="$url"; win_sig="$sig_content";; esac done # 构造 JSON(仅包含存在的目标) @@ -632,9 +705,14 @@ jobs: first=0 done fi - if [ -n "$win_url" ] && [ -n "$win_sig" ]; then + if [ -n "$win_x64_url" ] && [ -n "$win_x64_sig" ]; then [ $first -eq 0 ] && echo ',' - echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}" + echo " \"windows-x86_64\": {\"signature\": \"$win_x64_sig\", \"url\": \"$win_x64_url\"}" + first=0 + fi + if [ -n "$win_arm64_url" ] && [ -n "$win_arm64_sig" ]; then + [ $first -eq 0 ] && echo ',' + echo " \"windows-aarch64\": {\"signature\": \"$win_arm64_sig\", \"url\": \"$win_arm64_url\"}" first=0 fi if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then diff --git a/README.md b/README.md index 6254991c4..b717b6fa7 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,9 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Deutsch](README_
Click to collapse -[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) +[![Kimi K2.6](https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/2/2026-06-08/1d8j61pt3v89kkekm5mbg)](https://platform.moonshot.cn/console?aff=cc-switch) -MiniMax-M2.7 is a next-generation large language model designed for autonomous evolution and real-world productivity. Unlike traditional models, M2.7 actively participates in its own improvement through agent teams, dynamic tool use, and reinforcement learning loops. It delivers strong performance in software engineering (56.22% on SWE-Pro, 55.6% on VIBE-Pro, 57.0% on Terminal Bench 2) and excels in complex office workflows, achieving a leading 1495 ELO on GDPval-AA. With high-fidelity editing across Word, Excel, and PowerPoint, and a 97% adherence rate across 40+ complex skills, M2.7 sets a new standard for building AI-native workflows and organizations. - -[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Token Plan! +Kimi K2.6 is an open-source, native multimodal agentic model from Moonshot AI, built for long-horizon coding, coding-driven design, and swarm-based task orchestration. It is designed to handle complex end-to-end engineering work across front-end, DevOps, performance optimization, and full-stack workflows, while coordinating large groups of specialized agents to plan, implement, test, and iterate on real coding tasks. **[Click here to start using Kimi](https://platform.moonshot.cn/console?aff=cc-switch)** --- @@ -63,7 +61,7 @@ Register now via this lin BytePlus -Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via this link to get 500,000 tokens of free inference quota per model. >>中国大陆地区的开发者请点击这里 +Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via this link to get 500,000 tokens of free inference quota per model. >>中国大陆地区的开发者请点击这里 @@ -107,8 +105,8 @@ Register now via this lin -CTok -Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click here to register! +ETok +Thanks to ETok.ai for sponsoring this project! ETok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click here to register! @@ -142,7 +140,7 @@ Register now via this lin -CCSub +CCSub Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via this link and get $5 free credit on sign-up. diff --git a/README_DE.md b/README_DE.md index dfd1bf1c1..c33324c41 100644 --- a/README_DE.md +++ b/README_DE.md @@ -24,11 +24,9 @@
Zum Einklappen klicken -[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) +[![Kimi K2.6](https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/2/2026-06-08/1d8j61pt3v89kkekm5mbg)](https://platform.moonshot.cn/console?aff=cc-switch) -MiniMax-M2.7 ist ein großes Sprachmodell der nächsten Generation, das auf autonome Weiterentwicklung und praxisnahe Produktivität ausgelegt ist. Anders als herkömmliche Modelle beteiligt sich M2.7 aktiv an seiner eigenen Verbesserung — durch Agententeams, dynamische Werkzeugnutzung und Reinforcement-Learning-Schleifen. Es liefert starke Leistung im Software-Engineering (56,22 % bei SWE-Pro, 55,6 % bei VIBE-Pro, 57,0 % bei Terminal Bench 2) und überzeugt bei komplexen Büro-Workflows mit einem führenden Wert von 1495 ELO bei GDPval-AA. Mit originalgetreuer Bearbeitung von Word-, Excel- und PowerPoint-Dateien sowie einer Befolgungsrate von 97 % über 40+ komplexe Skills hinweg setzt M2.7 einen neuen Standard für den Aufbau KI-nativer Workflows und Organisationen. - -[Klicken Sie hier](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link), um exklusive 12 % Rabatt auf den MiniMax Token Plan zu erhalten! +Kimi K2.6 ist ein quelloffenes, nativ multimodales Agenten-Modell von Moonshot AI, das für langfristige Programmierung, code-getriebenes Design und schwarmbasierte Aufgabenorchestrierung entwickelt wurde. Es ist darauf ausgelegt, komplexe End-to-End-Engineering-Arbeiten über Frontend, DevOps, Performance-Optimierung und Full-Stack-Workflows hinweg zu bewältigen und dabei große Gruppen spezialisierter Agenten zu koordinieren, um echte Programmieraufgaben zu planen, umzusetzen, zu testen und zu iterieren. **[Hier klicken, um Kimi auszuprobieren](https://platform.moonshot.cn/console?aff=cc-switch)** --- @@ -63,7 +61,7 @@ Registrieren Sie sich jetzt über BytePlus -Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über diesen Link und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token. >>中国大陆地区的开发者请点击这里 +Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über diesen Link und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token. >>中国大陆地区的开发者请点击这里 @@ -107,8 +105,8 @@ Registrieren Sie sich jetzt über CTok -Danke an CTok.ai für die Unterstützung dieses Projekts! CTok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie hier, um sich zu registrieren! +ETok +Danke an ETok.ai für die Unterstützung dieses Projekts! ETok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie hier, um sich zu registrieren! @@ -142,7 +140,7 @@ Registrieren Sie sich jetzt über CCSub +CCSub Danke an CCSub für die Unterstützung dieses Projekts! CCSub ist eine zuverlässige und kostengünstige AI-API-Relay-Plattform — Ihr direkter Ersatz für ein Claude.ai-Abonnement. Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini und DeepSeek zu etwa 30 % der Kosten der direkten API-Nutzung — ohne VPN, weltweit nutzbar. Kompatibel mit Claude Code, Codex, Cursor, Cline, Continue, Windsurf und allen gängigen AI-Coding-Tools. Registrieren Sie sich über diesen Link und erhalten Sie $5 Startguthaben bei der Anmeldung. diff --git a/README_JA.md b/README_JA.md index 087c76d5a..2555ad13f 100644 --- a/README_JA.md +++ b/README_JA.md @@ -24,11 +24,9 @@
クリックで折りたたむ -[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) +[![Kimi K2.6](https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/2/2026-06-08/1d8j61pt3v89kkekm5mbg)](https://platform.moonshot.cn/console?aff=cc-switch) -MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設計された次世代大規模言語モデルです。従来のモデルとは異なり、M2.7 はエージェントチーム、動的ツール使用、強化学習ループを通じて自身の改善に積極的に参加します。ソフトウェアエンジニアリングにおいて優れた性能を発揮し(SWE-Pro で 56.22%、VIBE-Pro で 55.6%、Terminal Bench 2 で 57.0%)、複雑なオフィスワークフローにも秀でており、GDPval-AA で 1495 ELO のリーディングスコアを達成しています。Word・Excel・PowerPoint の高忠実度編集と、40 以上の複雑なスキルにわたる 97% の遵守率により、M2.7 は AI ネイティブなワークフローと組織構築の新基準を打ち立てます。 - -[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Token Plan の限定 12% オフを入手! +Kimi K2.6 は Moonshot AI によるオープンソースのネイティブマルチモーダル・エージェントモデルで、長期的なコーディング、コード駆動設計、スウォーム(群)型タスクオーケストレーションのために構築されています。フロントエンド、DevOps、パフォーマンス最適化、フルスタックワークフローにわたる複雑なエンドツーエンドのエンジニアリング作業に対応できるよう設計されており、多数の専門エージェントを協調させて、実際のコーディングタスクの計画・実装・テスト・反復を行います。**[ここをクリックして Kimi を体験する](https://platform.moonshot.cn/console?aff=cc-switch)** --- @@ -63,7 +61,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / BytePlus -Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。このリンクからご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。 >>中国大陆地区的开发者请点击这里 +Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。このリンクからご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。 >>中国大陆地区的开发者请点击这里 @@ -107,8 +105,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / -CTok -CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。こちらから登録してください! +ETok +ETok.ai のご支援に感謝します!ETok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。こちらから登録してください! @@ -142,7 +140,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / -CCSub +CCSub CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。こちらのリンクから登録すると $5 の無料クレジットがもらえます。 diff --git a/README_ZH.md b/README_ZH.md index 51ff6b8c9..e1356f1ac 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -24,11 +24,9 @@
点击折叠 -[![MiniMax](assets/partners/banners/minimax-zh.jpeg)](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link) +[![Kimi K2.6](https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/2/2026-06-08/1d8j61pt3v89kkekm5mbg)](https://platform.moonshot.cn/console?aff=cc-switch) -MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构建复杂 Agent Harness,并基于 Agent Teams、复杂 Skills、Tool Search Tool 等能力完成高复杂度生产力任务;其在软件工程、端到端项目交付及办公场景中表现优异,多项评测接近行业领先水平,同时具备稳定的复杂任务执行、环境交互能力以及良好的情商与身份保持能力。 - -[点击此处](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)享 MiniMax Token Plan 专属 88 折优惠! +Kimi K2.6 是 Moonshot AI 开源的原生多模态智能体模型,面向长程编码、代码驱动设计和智能体集群任务编排而构建。它适合处理复杂的端到端工程任务,覆盖前端开发、DevOps、性能优化和全栈工作流,并能够协调大规模专业智能体,对真实编码任务进行规划、实现、测试和迭代。**[点击此处开启 Kimi 使用体验](https://platform.moonshot.cn/console?aff=cc-switch)** --- @@ -62,8 +60,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 -HuoShan -感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!>>For developers outside Mainland China, please click here +HuoShan +感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!>>For developers outside Mainland China, please click here @@ -108,8 +106,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 -CTok -感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击这里注册! +ETok +感谢 ETok.ai 赞助了本项目!ETok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击这里注册! @@ -143,7 +141,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 -CCSub +CCSub 感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过此链接注册即送 $5 体验额度! diff --git a/assets/partners/logos/ccsub.jpg b/assets/partners/logos/ccsub.jpg deleted file mode 100644 index 92fae2452..000000000 Binary files a/assets/partners/logos/ccsub.jpg and /dev/null differ diff --git a/assets/partners/logos/ccsub.svg b/assets/partners/logos/ccsub.svg new file mode 100644 index 000000000..eaf5c8f3c --- /dev/null +++ b/assets/partners/logos/ccsub.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/partners/logos/ctok.png b/assets/partners/logos/ctok.png deleted file mode 100644 index 4b6e5ac66..000000000 Binary files a/assets/partners/logos/ctok.png and /dev/null differ diff --git a/assets/partners/logos/etok.png b/assets/partners/logos/etok.png new file mode 100644 index 000000000..cd1adbdd9 Binary files /dev/null and b/assets/partners/logos/etok.png differ diff --git a/docs/user-manual/en/1-getting-started/1.2-installation.md b/docs/user-manual/en/1-getting-started/1.2-installation.md index 8040a2cc2..cdd4f0988 100644 --- a/docs/user-manual/en/1-getting-started/1.2-installation.md +++ b/docs/user-manual/en/1-getting-started/1.2-installation.md @@ -122,9 +122,6 @@ npm install -g @google/gemini-cli ### Option 1: Homebrew (Recommended) ```bash -# Add tap -brew tap farion1231/ccswitch - # Install brew install --cask cc-switch ``` diff --git a/docs/user-manual/en/2-providers/2.5-usage-query.md b/docs/user-manual/en/2-providers/2.5-usage-query.md index 2b66e613f..d31c767ad 100644 --- a/docs/user-manual/en/2-providers/2.5-usage-query.md +++ b/docs/user-manual/en/2-providers/2.5-usage-query.md @@ -43,7 +43,7 @@ v3.13.0 provides **ready-to-use built-in templates** for the following categorie | Category | Covered Providers | Template Type | | ------------------ | --------------------------------------------------------- | ------------------------------- | -| Token Plan | Kimi / Zhipu GLM / MiniMax | Plan quota (with usage progress) | +| Token Plan | Kimi / Zhipu GLM / MiniMax / Volcengine | Plan quota (with usage progress) | | Third-party balance| DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | Official balance query | > **Tip**: Beyond these built-in templates, for uncovered providers you can use the **custom script** approach (see below) to write your own query logic. diff --git a/docs/user-manual/ja/1-getting-started/1.2-installation.md b/docs/user-manual/ja/1-getting-started/1.2-installation.md index a845191e0..a5f1eee85 100644 --- a/docs/user-manual/ja/1-getting-started/1.2-installation.md +++ b/docs/user-manual/ja/1-getting-started/1.2-installation.md @@ -122,9 +122,6 @@ npm install -g @google/gemini-cli ### 方法 1:Homebrew(推奨) ```bash -# tap を追加 -brew tap farion1231/ccswitch - # インストール brew install --cask cc-switch ``` diff --git a/docs/user-manual/ja/2-providers/2.5-usage-query.md b/docs/user-manual/ja/2-providers/2.5-usage-query.md index 7adc23e50..6a6de6a44 100644 --- a/docs/user-manual/ja/2-providers/2.5-usage-query.md +++ b/docs/user-manual/ja/2-providers/2.5-usage-query.md @@ -43,7 +43,7 @@ v3.13.0 では以下のカテゴリに **すぐに使える内蔵テンプレー | カテゴリ | 対象プロバイダー | テンプレートタイプ | | --------------- | --------------------------------------------------------- | ------------------------- | -| Token Plan | Kimi / Zhipu GLM / MiniMax | プランクォータ(使用進捗付き) | +| Token Plan | Kimi / Zhipu GLM / MiniMax / Volcengine(火山方舟) | プランクォータ(使用進捗付き) | | 第三者残高 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 公式残高クエリ | > **ヒント**:上記の内蔵テンプレート以外で対象外のプロバイダーには、**カスタムスクリプト** 方式(下記参照)で独自のクエリロジックを記述できます。 diff --git a/docs/user-manual/zh/1-getting-started/1.2-installation.md b/docs/user-manual/zh/1-getting-started/1.2-installation.md index eb592e592..8a547715a 100644 --- a/docs/user-manual/zh/1-getting-started/1.2-installation.md +++ b/docs/user-manual/zh/1-getting-started/1.2-installation.md @@ -138,9 +138,6 @@ npm install -g @google/gemini-cli --registry=https://registry.npmmirror.com ### 方式一:Homebrew(推荐) ```bash -# 添加 tap -brew tap farion1231/ccswitch - # 安装 brew install --cask cc-switch ``` diff --git a/docs/user-manual/zh/2-providers/2.5-usage-query.md b/docs/user-manual/zh/2-providers/2.5-usage-query.md index e78548e69..e3c6d2b8b 100644 --- a/docs/user-manual/zh/2-providers/2.5-usage-query.md +++ b/docs/user-manual/zh/2-providers/2.5-usage-query.md @@ -43,7 +43,7 @@ v3.13.0 为以下类别提供了**开箱即用的内置模板**,启用后无 | 类别 | 覆盖供应商 | 模板类型 | | ---------- | --------------------------------------------------------- | ----------------------- | -| Token Plan | Kimi / Zhipu GLM / MiniMax | 套餐配额(带使用进度) | +| Token Plan | Kimi / Zhipu GLM / MiniMax / 火山方舟 | 套餐配额(带使用进度) | | 第三方余额 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 官方余额查询 | > 💡 除了以上内置模板外,对未被覆盖的供应商,你可以使用**自定义脚本**方式(见下文)编写自己的查询逻辑。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8a9b6cbb7..60bd57a94 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -184,7 +184,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -215,7 +215,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -241,7 +241,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -421,6 +421,29 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", + "which", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -810,6 +833,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfb" version = "0.7.3" @@ -867,6 +899,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -936,6 +979,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1178,7 +1230,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -1385,6 +1437,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.6" @@ -1914,7 +1972,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2222,6 +2280,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2616,6 +2683,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -2753,6 +2829,18 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2779,7 +2867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -2799,6 +2887,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.14" @@ -2822,6 +2920,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2954,6 +3058,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.5" @@ -3071,6 +3181,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -3839,7 +3959,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4002,7 +4122,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "socket2", "thiserror 2.0.18", @@ -4022,7 +4142,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", @@ -4278,6 +4398,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "rend" version = "0.4.2" @@ -4447,6 +4573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c" dependencies = [ "rquickjs-core", + "rquickjs-macro", ] [[package]] @@ -4455,15 +4582,34 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b" dependencies = [ + "relative-path", "rquickjs-sys", ] +[[package]] +name = "rquickjs-macro" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6041104330c019fcd936026ae05e2446f5e8a2abef329d924f25424b7052a2f3" +dependencies = [ + "convert_case 0.6.0", + "fnv", + "ident_case", + "indexmap 2.13.0", + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "rquickjs-core", + "syn 2.0.117", +] + [[package]] name = "rquickjs-sys" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a" dependencies = [ + "bindgen", "cc", ] @@ -4507,6 +4653,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -4522,6 +4674,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4531,7 +4696,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4775,7 +4940,7 @@ dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", "precomputed-hash", - "rustc-hash", + "rustc-hash 2.1.1", "servo_arc 0.4.3", "smallvec", ] @@ -5814,7 +5979,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6783,6 +6948,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7527,7 +7704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.4", "x11rb-protocol", ] @@ -7544,7 +7721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -7601,7 +7778,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d0cd47560..9f7841511 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -91,6 +91,9 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] } winreg = "0.52" windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] } +[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies] +rquickjs = { version = "0.8", features = ["bindgen"] } + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.5" objc2-app-kit = { version = "0.2", features = ["NSColor"] } diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 0824aedf1..8b3de69f2 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -1339,8 +1339,10 @@ mod tests { } fn set_proxy_port(db: &Database, port: u16) { - let mut config = crate::proxy::types::ProxyConfig::default(); - config.listen_port = port; + let config = crate::proxy::types::ProxyConfig { + listen_port: port, + ..Default::default() + }; futures::executor::block_on(db.update_proxy_config(config)).expect("update proxy config"); } diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index c35a05f27..9da3f63fd 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -4,7 +4,7 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; -use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path}; +use crate::config::{atomic_write, get_claude_mcp_path}; use crate::error::AppError; /// 需要在 Windows 上用 cmd /c 包装的命令 @@ -98,51 +98,9 @@ pub struct McpStatus { } fn user_config_path() -> PathBuf { - ensure_mcp_override_migrated(); get_claude_mcp_path() } -fn ensure_mcp_override_migrated() { - if crate::settings::get_claude_override_dir().is_none() { - return; - } - - let new_path = get_claude_mcp_path(); - if new_path.exists() { - return; - } - - let legacy_path = get_default_claude_mcp_path(); - if !legacy_path.exists() { - return; - } - - if let Some(parent) = new_path.parent() { - if let Err(err) = fs::create_dir_all(parent) { - log::warn!("创建 MCP 目录失败: {err}"); - return; - } - } - - match fs::copy(&legacy_path, &new_path) { - Ok(_) => { - log::info!( - "已根据覆盖目录复制 MCP 配置: {} -> {}", - legacy_path.display(), - new_path.display() - ); - } - Err(err) => { - log::warn!( - "复制 MCP 配置失败: {} -> {}: {}", - legacy_path.display(), - new_path.display(), - err - ); - } - } -} - fn read_json_value(path: &Path) -> Result { if !path.exists() { return Ok(serde_json::json!({})); diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index 240ce5e72..648af4d81 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -67,6 +67,7 @@ const CC_SWITCH_LEGACY_CODEX_MODEL_PROVIDER_IDS: &[&str] = &[ "dmxapi", "doubaoseed", "eflowcode", + "etok", "kimi", "lemondata", "longcat", @@ -1116,16 +1117,23 @@ fn migrate_codex_state_dbs( } fn codex_state_db_paths(codex_dir: &Path, config_text: &str) -> Vec { - let mut paths = vec![codex_dir.join(CODEX_STATE_DB_FILENAME)]; + let mut paths = Vec::new(); + push_unique_path(&mut paths, codex_dir.join(CODEX_STATE_DB_FILENAME)); + // Codex lets SQLite state move away from CODEX_HOME; config takes precedence. if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) { - let db_path = sqlite_home.join(CODEX_STATE_DB_FILENAME); - if !paths.contains(&db_path) { - paths.push(db_path); - } + push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); + } else if let Some(sqlite_home) = sqlite_home_from_env() { + push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); } paths } +fn push_unique_path(paths: &mut Vec, path: PathBuf) { + if !paths.contains(&path) { + paths.push(path); + } +} + fn sqlite_home_from_codex_config(config_text: &str) -> Option { let doc = config_text.parse::().ok()?; let raw = doc.get("sqlite_home")?.as_str()?.trim(); @@ -1135,6 +1143,15 @@ fn sqlite_home_from_codex_config(config_text: &str) -> Option { Some(resolve_user_path(raw)) } +fn sqlite_home_from_env() -> Option { + let raw = std::env::var("CODEX_SQLITE_HOME").ok()?; + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + Some(resolve_user_path(raw)) +} + fn resolve_user_path(raw: &str) -> PathBuf { if raw == "~" { return crate::config::get_home_dir(); @@ -1313,8 +1330,33 @@ fn relative_backup_path(path: &Path, root: &Path) -> PathBuf { mod tests { use super::*; use crate::provider::Provider; + use serial_test::serial; + use std::ffi::OsString; use tempfile::tempdir; + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); + } + } + } + fn source_ids(values: &[&str]) -> BTreeSet { values.iter().map(|value| value.to_string()).collect() } @@ -2116,6 +2158,46 @@ base_url = "https://proxy.example/v1" assert_eq!(backed_up_source_count, 2); } + #[test] + #[serial] + fn state_db_paths_include_codex_sqlite_home_env() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let sqlite_home = dir.path().join("sqlite-home"); + let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &sqlite_home); + + let paths = codex_state_db_paths(&codex_dir, ""); + + assert_eq!( + paths, + vec![ + codex_dir.join(CODEX_STATE_DB_FILENAME), + sqlite_home.join(CODEX_STATE_DB_FILENAME), + ] + ); + } + + #[test] + #[serial] + fn config_sqlite_home_takes_precedence_over_codex_sqlite_home_env() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let env_sqlite_home = dir.path().join("env-sqlite-home"); + let config_sqlite_home = dir.path().join("config-sqlite-home"); + let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &env_sqlite_home); + let config_text = format!("sqlite_home = \"{}\"\n", config_sqlite_home.display()); + + let paths = codex_state_db_paths(&codex_dir, &config_text); + + assert_eq!( + paths, + vec![ + codex_dir.join(CODEX_STATE_DB_FILENAME), + config_sqlite_home.join(CODEX_STATE_DB_FILENAME), + ] + ); + } + #[test] fn collects_third_party_provider_ids_from_codex_providers() { let db = Database::memory().expect("memory db"); diff --git a/src-tauri/src/commands/coding_plan.rs b/src-tauri/src/commands/coding_plan.rs index 0654cd8e8..49b9b20be 100644 --- a/src-tauri/src/commands/coding_plan.rs +++ b/src-tauri/src/commands/coding_plan.rs @@ -4,6 +4,15 @@ use crate::services::subscription::SubscriptionQuota; pub async fn get_coding_plan_quota( base_url: String, api_key: String, + // 火山方舟用控制面 AK/SK 签名查询用量;其他供应商不传,沿用 api_key。 + access_key_id: Option, + secret_access_key: Option, ) -> Result { - crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await + crate::services::coding_plan::get_coding_plan_quota( + &base_url, + &api_key, + access_key_id.as_deref(), + secret_access_key.as_deref(), + ) + .await } diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 3abb43e87..90ed3b28a 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1069,6 +1069,111 @@ fn default_flag_for_shell(shell: &str) -> &'static str { } } +fn fallback_user_shell() -> &'static str { + if cfg!(target_os = "macos") { + "/bin/zsh" + } else { + "/bin/bash" + } +} + +fn valid_user_shell_path(shell: &str) -> bool { + if shell.is_empty() + || !shell.starts_with('/') + || !is_valid_shell(shell) + || shell.chars().any(char::is_control) + { + return false; + } + + let path = std::path::Path::new(shell); + path.is_file() && is_executable_file(path) +} + +#[cfg(unix)] +fn is_executable_file(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable_file(path: &std::path::Path) -> bool { + path.is_file() +} + +/// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。 +fn get_user_shell() -> String { + std::env::var("SHELL") + .ok() + .filter(|shell| valid_user_shell_path(shell)) + .unwrap_or_else(|| fallback_user_shell().to_string()) +} + +/// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。 +fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String { + let quoted_shell = shell_single_quote(shell); + + match shell.rsplit('/').next().unwrap_or(shell) { + "zsh" => cwd + .map(|dir| { + let command = format!( + "cd {} || exit 1; exec {} -i", + shell_single_quote(&dir.to_string_lossy()), + quoted_shell + ); + format!("exec {} -lc {}", quoted_shell, shell_single_quote(&command)) + }) + .unwrap_or_else(|| format!("exec {quoted_shell} -l")), + _ => format!("exec {quoted_shell}"), + } +} + +/// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。 +fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String { + let claude_command = format!("claude --settings {}", shell_single_quote(config_path)); + let command = cwd + .map(|dir| { + format!( + "cd {} && {}", + shell_single_quote(&dir.to_string_lossy()), + claude_command + ) + }) + .unwrap_or(claude_command); + + format!( + "{} {} {}", + shell_single_quote(shell), + provider_command_flag_for_shell(shell), + shell_single_quote(&command) + ) +} + +fn provider_command_flag_for_shell(shell: &str) -> &'static str { + match shell.rsplit('/').next().unwrap_or(shell) { + "dash" | "sh" => "-c", + "zsh" => "-lic", + _ => "-ic", + } +} + +fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String { + if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") { + return String::new(); + } + + cwd.map(|dir| { + format!( + "cd {} || exit 1\n", + shell_single_quote(&dir.to_string_lossy()) + ) + }) + .unwrap_or_default() +} + #[cfg(target_os = "windows")] fn try_get_version_wsl( tool: &str, @@ -2642,24 +2747,31 @@ fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R let preferred = crate::settings::get_preferred_terminal(); let terminal = preferred.as_deref().unwrap_or("terminal"); + let shell = get_user_shell(); + let exec_line = build_exec_line(&shell, cwd); + let final_cd_command = build_final_shell_cd_command(&shell, cwd); + let temp_dir = std::env::temp_dir(); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let config_path = config_file.to_string_lossy(); - let cd_command = build_shell_cd_command(cwd); + let provider_command = build_provider_command_line(&shell, &config_path, cwd); // Write the shell script to a temp file + // 脚本使用 POSIX sh 语法确保可移植性,exec 行切换到用户交互式 shell let script_content = format!( - r#"#!/bin/bash + r#"#!/usr/bin/env sh trap 'rm -f "{config_path}" "{script_file}"' EXIT -{cd_command} echo "Using provider-specific claude config:" echo "{config_path}" -claude --settings "{config_path}" -exec bash --norc --noprofile +{provider_command} +{final_cd_command} +{exec_line} "#, config_path = config_path, script_file = script_file.display(), - cd_command = cd_command, + provider_command = provider_command, + final_cd_command = final_cd_command, + exec_line = exec_line, ); std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; @@ -2678,7 +2790,7 @@ exec bash --norc --noprofile "ghostty" => launch_macos_ghostty(&script_file), "wezterm" => launch_macos_open_app("WezTerm", &script_file, true), "kaku" => launch_macos_open_app("Kaku", &script_file, true), - _ => launch_macos_terminal_app(&script_file), // "terminal" or default + _ => launch_macos_terminal_app(&script_file), }; // If preferred terminal fails and it's not the default, try Terminal.app as fallback @@ -2704,7 +2816,16 @@ fn applescript_string_literal(value: &str) -> String { #[cfg(target_os = "macos")] fn applescript_launcher_command(script_file: &std::path::Path) -> String { applescript_string_literal(&format!( - "bash {}", + "sh {}", + shell_single_quote(&script_file.to_string_lossy()) + )) +} + +/// Build a launcher command that replaces the terminal-created shell session. +#[cfg(target_os = "macos")] +fn applescript_exec_launcher_command(script_file: &std::path::Path) -> String { + applescript_string_literal(&format!( + "exec sh {}", shell_single_quote(&script_file.to_string_lossy()) )) } @@ -2727,7 +2848,7 @@ tell application "Terminal" activate end if end tell"#, - launcher = applescript_launcher_command(script_file) + launcher = applescript_exec_launcher_command(script_file) ) } @@ -2795,7 +2916,7 @@ tell application "iTerm" write text launcher_script end tell end tell"#, - launcher = applescript_launcher_command(script_file) + launcher = applescript_exec_launcher_command(script_file) ) } @@ -2805,12 +2926,12 @@ fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> { run_terminal_osascript(&build_macos_iterm2_applescript(script_file), "iTerm2") } -/// Keep the launcher path inside a `bash -c` string. +/// Keep the launcher path inside a `sh -c` string. /// A bare `.sh` passed through `open --args` may also be opened as a document. #[cfg(target_os = "macos")] fn build_macos_dash_c_command(script_file: &std::path::Path) -> String { format!( - "exec bash {}", + "exec sh {}", shell_single_quote(&script_file.to_string_lossy()) ) } @@ -2865,8 +2986,8 @@ fn launch_macos_open_app( if use_e_flag { cmd.arg("-e"); } - // Keep the script path inside `bash -c`; a trailing bare `.sh` can be opened as a document. - cmd.arg("bash") + // Keep the script path inside `sh -c`; a trailing bare `.sh` can be opened as a document. + cmd.arg("sh") .arg("-c") .arg(build_macos_dash_c_command(script_file)); @@ -2912,9 +3033,9 @@ fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> { rm -- "$0" - exec bash {} + exec sh {quoted_script} "#, - script_file.display(), + quoted_script = shell_single_quote(&script_file.to_string_lossy()), ) .map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?; @@ -2946,6 +3067,10 @@ fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R let preferred = crate::settings::get_preferred_terminal(); + let shell = get_user_shell(); + let exec_line = build_exec_line(&shell, cwd); + let final_cd_command = build_final_shell_cd_command(&shell, cwd); + // Default terminal list with their arguments let default_terminals = [ ("gnome-terminal", vec!["--"]), @@ -2962,20 +3087,22 @@ fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R let temp_dir = std::env::temp_dir(); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let config_path = config_file.to_string_lossy(); - let cd_command = build_shell_cd_command(cwd); + let provider_command = build_provider_command_line(&shell, &config_path, cwd); let script_content = format!( - r#"#!/bin/bash + r#"#!/usr/bin/env sh trap 'rm -f "{config_path}" "{script_file}"' EXIT -{cd_command} echo "Using provider-specific claude config:" echo "{config_path}" -claude --settings "{config_path}" -exec bash --norc --noprofile +{provider_command} +{final_cd_command} +{exec_line} "#, config_path = config_path, script_file = script_file.display(), - cd_command = cd_command, + provider_command = provider_command, + final_cd_command = final_cd_command, + exec_line = exec_line, ); std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; @@ -3019,7 +3146,7 @@ exec bash --norc --noprofile if terminal_exists { let result = Command::new(terminal) .args(&args) - .arg("bash") + .arg("sh") .arg(script_file.to_string_lossy().as_ref()) .spawn(); @@ -3106,16 +3233,6 @@ del \"%~f0\" >nul 2>&1 result } -fn build_shell_cd_command(cwd: Option<&Path>) -> String { - cwd.map(|dir| { - format!( - "cd {} || exit 1\n", - shell_single_quote(&dir.to_string_lossy()) - ) - }) - .unwrap_or_default() -} - fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } @@ -3182,7 +3299,7 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S Ok(()) } -/// 打开用户首选终端并在其中执行一段可信命令脚本。脚本尾部 `read -n 1` / `pause` +/// 打开用户首选终端并在其中执行一段可信命令脚本。脚本尾部 `read -r` / `pause` /// 是刻意设计的——让命令退出后窗口不要瞬间关闭,用户才看得到 `command /// not found` / `ModuleNotFoundError` 这类诊断信息。 /// @@ -3196,14 +3313,14 @@ pub(crate) fn launch_terminal_running(command_line: &str, label: &str) -> Result let (script_file, script_content) = { let file = temp_dir.join(format!("cc_switch_{}_{}.sh", label, pid)); let content = format!( - r#"#!/bin/bash + r#"#!/usr/bin/env sh trap 'rm -f "{script_path}"' EXIT echo "[cc-switch] Starting: {label}" echo "" {cmd} echo "" -echo "[cc-switch] Command exited. Press any key to close." -read -n 1 -s +echo "[cc-switch] Command exited. Press Enter to close." +read -r _ "#, script_path = file.display(), label = label, @@ -3299,7 +3416,7 @@ read -n 1 -s if terminal_exists { let spawn_result = Command::new(terminal) .args(&args) - .arg("bash") + .arg("sh") .arg(script_file.to_string_lossy().as_ref()) .spawn(); match spawn_result { @@ -3387,6 +3504,126 @@ mod tests { use super::*; use std::path::{Path, PathBuf}; + #[cfg(unix)] + fn set_test_executable(path: &Path, executable: bool) { + use std::os::unix::fs::PermissionsExt; + + let mode = if executable { 0o755 } else { 0o644 }; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .expect("fixture permissions should be set"); + } + + #[test] + fn test_build_exec_line() { + assert_eq!(build_exec_line("/bin/zsh", None), "exec '/bin/zsh' -l"); + assert_eq!(build_exec_line("/bin/bash", None), "exec '/bin/bash'"); + assert_eq!( + build_exec_line("/opt/homebrew dir/bin/fish", None), + "exec '/opt/homebrew dir/bin/fish'" + ); + assert_eq!(build_exec_line("/bin/sh", None), "exec '/bin/sh'"); + assert_eq!( + build_exec_line("/tmp/shell'quote/zsh", None), + "exec '/tmp/shell'\"'\"'quote/zsh' -l" + ); + assert_eq!( + build_exec_line("/bin/zsh", Some(Path::new("/tmp/project"))), + r#"exec '/bin/zsh' -lc 'cd '"'"'/tmp/project'"'"' || exit 1; exec '"'"'/bin/zsh'"'"' -i'"# + ); + } + + #[test] + fn test_build_provider_command_line_uses_user_shell_environment() { + assert_eq!( + build_provider_command_line("/bin/zsh", "/tmp/claude config.json", None), + "'/bin/zsh' -lic 'claude --settings '\"'\"'/tmp/claude config.json'\"'\"''" + ); + assert_eq!( + build_provider_command_line( + "/bin/bash", + "/tmp/claude config.json", + Some(Path::new("/tmp/project")) + ), + r#"'/bin/bash' -ic 'cd '"'"'/tmp/project'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"# + ); + assert_eq!( + build_provider_command_line( + "/bin/sh", + "/tmp/claude config.json", + Some(Path::new("/tmp/project O'Brien")) + ), + r#"'/bin/sh' -c 'cd '"'"'/tmp/project O'"'"'"'"'"'"'"'"'Brien'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"# + ); + } + + #[test] + fn test_build_final_shell_cd_command() { + assert_eq!(build_final_shell_cd_command("/bin/zsh", None), ""); + assert_eq!( + build_final_shell_cd_command("/bin/zsh", Some(Path::new("/tmp/project"))), + "" + ); + assert_eq!( + build_final_shell_cd_command("/bin/bash", Some(Path::new("/tmp/project O'Brien"))), + "cd '/tmp/project O'\"'\"'Brien' || exit 1\n" + ); + } + + #[cfg(unix)] + #[test] + fn test_get_user_shell_fallback() { + // $SHELL 未设置时应按平台 fallback + // 此测试验证 fallback 逻辑,但不验证环境变量值(取决于运行环境) + let shell = get_user_shell(); + // 至少应返回一个合法的绝对路径 + assert!(valid_user_shell_path(&shell)); + // basename 应为合法 shell 名 + let basename = shell.rsplit('/').next().unwrap_or("sh"); + assert!(["sh", "bash", "zsh", "fish", "dash"].contains(&basename)); + } + + #[cfg(unix)] + #[test] + fn test_valid_user_shell_path() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let executable_zsh = temp.path().join("zsh"); + std::fs::write(&executable_zsh, "#!/usr/bin/env sh\n") + .expect("shell fixture should be written"); + set_test_executable(&executable_zsh, true); + + let executable_fish_dir = temp.path().join("homebrew dir/bin"); + std::fs::create_dir_all(&executable_fish_dir) + .expect("shell fixture directory should be created"); + let executable_fish = executable_fish_dir.join("fish"); + std::fs::write(&executable_fish, "#!/usr/bin/env sh\n") + .expect("shell fixture should be written"); + set_test_executable(&executable_fish, true); + + let non_executable_bash = temp.path().join("bash"); + std::fs::write(&non_executable_bash, "#!/usr/bin/env sh\n") + .expect("shell fixture should be written"); + set_test_executable(&non_executable_bash, false); + + assert!(valid_user_shell_path(&executable_zsh.to_string_lossy())); + assert!(valid_user_shell_path(&executable_fish.to_string_lossy())); + assert!(!valid_user_shell_path("")); + assert!(!valid_user_shell_path("zsh")); + assert!(!valid_user_shell_path( + &temp.path().join("missing/zsh").to_string_lossy() + )); + assert!(!valid_user_shell_path( + &non_executable_bash.to_string_lossy() + )); + assert!(!valid_user_shell_path( + &temp.path().join("zsh; rm -rf /").to_string_lossy() + )); + assert!(!valid_user_shell_path(&format!( + "{}\n/bin/bash", + executable_zsh.to_string_lossy() + ))); + assert!(!valid_user_shell_path("/usr/bin/powershell")); + } + #[test] fn test_extract_version() { assert_eq!(extract_version("claude 1.0.20"), "1.0.20"); @@ -4850,13 +5087,6 @@ mod tests { assert!(error.contains("目录不存在")); } - #[test] - fn build_shell_cd_command_quotes_spaces_and_single_quotes() { - let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien"))); - - assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n"); - } - #[cfg(target_os = "macos")] #[test] fn iterm2_applescript_cold_start_avoids_current_window_before_one_exists() { @@ -4918,6 +5148,10 @@ mod tests { ), "already-running branch should use bare do script:\n{script}" ); + assert!( + script.contains(r#"set launcher_script to "exec sh '/tmp/cc_switch_launcher.sh'""#), + "Terminal should replace the auto-created shell:\n{script}" + ); } /// Restored windows should not receive the launcher command. @@ -4943,7 +5177,7 @@ mod tests { // Warm launches execute through the AppleScript command property, not `open -na ... -e`. assert!( - script.contains(r#"set launcher_command to "bash '/tmp/cc_switch_launcher.sh'""#), + script.contains(r#"set launcher_command to "sh '/tmp/cc_switch_launcher.sh'""#), "missing launcher_command:\n{script}" ); assert!(script.contains("if was_running then")); @@ -4983,11 +5217,11 @@ mod tests { fn dash_c_command_wraps_script_path_inside_quoted_arg() { // The script path must stay inside the `-c` string, not as a bare argv. let s = build_macos_dash_c_command(Path::new("/tmp/cc_switch_launcher_1.sh")); - assert_eq!(s, "exec bash '/tmp/cc_switch_launcher_1.sh'"); + assert_eq!(s, "exec sh '/tmp/cc_switch_launcher_1.sh'"); // Spaces and single quotes must stay shell-safe too. let s2 = build_macos_dash_c_command(Path::new("/Users/me/it's dir/x.sh")); - assert_eq!(s2, r#"exec bash '/Users/me/it'"'"'s dir/x.sh'"#); + assert_eq!(s2, r#"exec sh '/Users/me/it'"'"'s dir/x.sh'"#); } /// AppleScript launchers need both shell-path quoting and AppleScript string quoting. @@ -4995,20 +5229,26 @@ mod tests { #[test] fn applescript_builders_safely_quote_special_paths() { // First shell-quote the path, then wrap the whole command as an AppleScript string. - let expected = r#""bash '/Users/me/it'\"'\"'s dir/x.sh'""#; + let expected = r#""sh '/Users/me/it'\"'\"'s dir/x.sh'""#; let p = Path::new("/Users/me/it's dir/x.sh"); assert_eq!(applescript_launcher_command(p), expected); + assert_eq!( + applescript_exec_launcher_command(p), + r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""# + ); assert!( - build_macos_terminal_applescript(p).contains(expected), + build_macos_terminal_applescript(p) + .contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#), "Terminal did not quote safely" ); assert!( - build_macos_iterm2_applescript(p).contains(expected), + build_macos_iterm2_applescript(p) + .contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#), "iTerm2 did not quote safely" ); assert!( build_macos_ghostty_applescript(p).contains(expected), - "Ghostty did not quote safely" + "Ghostty did not keep the non-exec launcher" ); } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 8c726af08..d61d677fb 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -514,9 +514,19 @@ async fn query_provider_usage_inner( let (base_url, api_key) = resolve_coding_plan_credentials(&app_type, provider, usage_script); - let quota = crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key) - .await - .map_err(|e| format!("Failed to query coding plan: {e}"))?; + // 火山方舟用账号 AK/SK 签名查询用量(存于 usage_script,与推理 api_key 分离); + // 其他供应商为 None,service 层沿用 api_key。 + let access_key_id = usage_script.and_then(|s| s.access_key_id.clone()); + let secret_access_key = usage_script.and_then(|s| s.secret_access_key.clone()); + + let quota = crate::services::coding_plan::get_coding_plan_quota( + &base_url, + &api_key, + access_key_id.as_deref(), + secret_access_key.as_deref(), + ) + .await + .map_err(|e| format!("Failed to query coding plan: {e}"))?; // 将 SubscriptionQuota 转换为 UsageResult if !quota.success { @@ -1086,6 +1096,8 @@ mod native_query_credentials_tests { template_type: Some("token_plan".to_string()), auto_query_interval: None, coding_plan_provider: coding_plan_provider.map(str::to_string), + access_key_id: None, + secret_access_key: None, } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 085ca7724..ed4686522 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::fs; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use crate::error::AppError; @@ -47,25 +47,118 @@ pub fn get_default_claude_mcp_path() -> PathBuf { get_home_dir().join(".claude.json") } -fn derive_mcp_path_from_override(dir: &Path) -> Option { - let file_name = dir - .file_name() - .map(|name| name.to_string_lossy().to_string())? - .trim() - .to_string(); - if file_name.is_empty() { - return None; +fn normalize_path_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + normalized.push(component.as_os_str()); + } + } + Component::Normal(part) => normalized.push(part), + Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()), + } } - let parent = dir.parent().unwrap_or_else(|| Path::new("")); - Some(parent.join(format!("{file_name}.json"))) + + normalized } -/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级 +fn comparable_path_key(path: &Path) -> String { + let mut key = normalize_path_lexically(path).to_string_lossy().to_string(); + + #[cfg(windows)] + { + key = key.replace('\\', "/"); + } + + while key.len() > 1 && key.ends_with('/') { + key.pop(); + } + + #[cfg(windows)] + { + key.make_ascii_lowercase(); + } + + key +} + +fn path_eq_lexical(left: &Path, right: &Path) -> bool { + comparable_path_key(left) == comparable_path_key(right) +} + +#[cfg(windows)] +fn derive_wsl_default_mcp_path(dir: &Path) -> Option { + use std::path::Prefix; + + let normalized = normalize_path_lexically(dir); + let mut components = normalized.components(); + let prefix = match components.next()? { + Component::Prefix(prefix) => prefix, + _ => return None, + }; + + let server = match prefix.kind() { + Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => server.to_string_lossy(), + _ => return None, + }; + + if !server.eq_ignore_ascii_case("wsl$") && !server.eq_ignore_ascii_case("wsl.localhost") { + return None; + } + + let mut parts = Vec::new(); + for component in components { + match component { + Component::RootDir | Component::CurDir => {} + Component::Normal(part) => parts.push(part.to_string_lossy().to_string()), + Component::ParentDir | Component::Prefix(_) => return None, + } + } + + let is_wsl_home_default = + parts.len() == 3 && parts[0] == "home" && !parts[1].is_empty() && parts[2] == ".claude"; + let is_wsl_root_default = parts.len() == 2 && parts[0] == "root" && parts[1] == ".claude"; + + if is_wsl_home_default || is_wsl_root_default { + return normalized + .parent() + .map(|parent| parent.join(".claude.json")); + } + + None +} + +fn default_mcp_path_for_config_dir(dir: &Path) -> Option { + let default_config_dir = get_home_dir().join(".claude"); + if path_eq_lexical(dir, &default_config_dir) { + return Some(get_default_claude_mcp_path()); + } + + #[cfg(windows)] + { + if let Some(path) = derive_wsl_default_mcp_path(dir) { + return Some(path); + } + } + + None +} + +fn derive_mcp_path_from_override(dir: &Path) -> PathBuf { + dir.join(".claude.json") +} + +/// 获取 Claude MCP 配置文件路径 pub fn get_claude_mcp_path() -> PathBuf { if let Some(custom_dir) = crate::settings::get_claude_override_dir() { - if let Some(path) = derive_mcp_path_from_override(&custom_dir) { + if let Some(path) = default_mcp_path_for_config_dir(&custom_dir) { return path; } + return derive_mcp_path_from_override(&custom_dir); } get_default_claude_mcp_path() } @@ -263,33 +356,73 @@ mod tests { use super::*; #[test] - fn derive_mcp_path_from_override_preserves_folder_name() { + fn derive_mcp_path_from_override_uses_config_dir_for_custom_path() { let override_dir = PathBuf::from("/tmp/profile/.claude"); - let derived = derive_mcp_path_from_override(&override_dir) - .expect("should derive path for nested dir"); - assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json")); + let derived = derive_mcp_path_from_override(&override_dir); + assert_eq!(derived, PathBuf::from("/tmp/profile/.claude/.claude.json")); } #[test] - fn derive_mcp_path_from_override_handles_non_hidden_folder() { + fn derive_mcp_path_from_override_uses_config_dir_for_non_hidden_folder() { let override_dir = PathBuf::from("/data/claude-config"); - let derived = derive_mcp_path_from_override(&override_dir) - .expect("should derive path for standard dir"); - assert_eq!(derived, PathBuf::from("/data/claude-config.json")); + let derived = derive_mcp_path_from_override(&override_dir); + assert_eq!(derived, PathBuf::from("/data/claude-config/.claude.json")); } #[test] fn derive_mcp_path_from_override_supports_relative_rootless_dir() { let override_dir = PathBuf::from("claude"); - let derived = derive_mcp_path_from_override(&override_dir) - .expect("should derive path for single segment"); - assert_eq!(derived, PathBuf::from("claude.json")); + let derived = derive_mcp_path_from_override(&override_dir); + assert_eq!(derived, PathBuf::from("claude/.claude.json")); } #[test] - fn derive_mcp_path_from_root_like_dir_returns_none() { + fn derive_mcp_path_from_root_like_dir_uses_root_file() { let override_dir = PathBuf::from("/"); - assert!(derive_mcp_path_from_override(&override_dir).is_none()); + let derived = derive_mcp_path_from_override(&override_dir); + assert_eq!(derived, PathBuf::from("/.claude.json")); + } + + #[test] + fn derive_mcp_path_from_override_preserves_leading_parent_dirs() { + let override_dir = PathBuf::from("../../profiles/work/.claude"); + let derived = derive_mcp_path_from_override(&override_dir); + assert_eq!(derived, override_dir.join(".claude.json")); + } + + #[cfg(windows)] + #[test] + fn wsl_unc_home_default_uses_split_mcp_path() { + let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude"); + let derived = default_mcp_path_for_config_dir(&override_dir) + .expect("WSL home default should use split MCP path"); + assert_eq!( + derived, + PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude.json") + ); + } + + #[cfg(windows)] + #[test] + fn wsl_unc_root_default_uses_split_mcp_path() { + let override_dir = PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude"); + let derived = default_mcp_path_for_config_dir(&override_dir) + .expect("WSL root default should use split MCP path"); + assert_eq!( + derived, + PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude.json") + ); + } + + #[cfg(windows)] + #[test] + fn wsl_unc_custom_dir_uses_nested_mcp_path() { + let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude"); + assert!(default_mcp_path_for_config_dir(&override_dir).is_none()); + assert_eq!( + derive_mcp_path_from_override(&override_dir), + PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude\.claude.json") + ); } #[test] diff --git a/src-tauri/src/database/dao/usage_rollup.rs b/src-tauri/src/database/dao/usage_rollup.rs index 3c2bc8994..eaa472798 100644 --- a/src-tauri/src/database/dao/usage_rollup.rs +++ b/src-tauri/src/database/dao/usage_rollup.rs @@ -493,8 +493,10 @@ mod tests { { let conn = crate::database::lock_conn!(db.conn); - let date_str = chrono::DateTime::from_timestamp(old_ts, 0) - .unwrap() + let date_str = Local + .timestamp_opt(old_ts, 0) + .single() + .expect("old timestamp should be a valid local datetime") .format("%Y-%m-%d") .to_string(); conn.execute( diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index b4050ce76..cc5e3f8f4 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1853,6 +1853,7 @@ impl Database { ("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"), ("glm-5", "GLM-5", "1", "3.2", "0.2", "0"), ("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"), + ("glm-5.2", "GLM-5.2", "1.4", "4.4", "0.26", "0"), // MiMo (小米) ( "mimo-v2-flash", diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 84f467cd0..821475ccb 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -235,6 +235,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, + /// 火山方舟控制面 OpenAPI 的 AccessKey ID(用量查询签名用,与推理 Key 是两套凭据) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "accessKeyId")] + pub access_key_id: Option, + /// 火山方舟控制面 OpenAPI 的 SecretAccessKey(同上) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "secretAccessKey")] + pub secret_access_key: Option, } /// 用量数据 diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index e83e2f68e..593b19807 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1350,7 +1350,7 @@ impl RequestForwarder { .await; if restored > 0 { log::debug!( - "[Codex] Restored {restored} cached function call(s) for Chat upstream" + "[Codex] Restored or enriched {restored} cached function call item(s) for Chat upstream" ); } super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body); diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 51a996386..c8db6b919 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -145,6 +145,77 @@ pub fn normalize_anthropic_tool_thinking_history_for_provider( normalize_anthropic_tool_thinking_history(body) } +/// DeepSeek official Anthropic-compatible endpoint URL +const DEEPSEEK_OFFICIAL_ANTHROPIC_URL: &str = "https://api.deepseek.com/anthropic"; + +/// Check whether the provider is configured to use DeepSeek's official +/// Anthropic-compatible endpoint. +fn is_deepseek_official_anthropic_endpoint(provider: &Provider) -> bool { + let settings = &provider.settings_config; + let base_url = settings + .get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()) + .or_else(|| settings.get("base_url").and_then(|v| v.as_str())) + .or_else(|| settings.get("baseURL").and_then(|v| v.as_str())) + .or_else(|| settings.get("apiEndpoint").and_then(|v| v.as_str())); + + base_url.map(|u| u.trim_end_matches('/')) == Some(DEEPSEEK_OFFICIAL_ANTHROPIC_URL) +} + +/// DeepSeek's official Anthropic-compatible endpoint treats +/// `thinking: { type: "disabled" }` and effort parameters (`output_config.effort` +/// or `reasoning_effort`) as mutually exclusive, returning HTTP 400: +/// "thinking options type cannot be disabled when reasoning_effort is set". +/// This breaks Claude Code 2.1.166+ Workflow/Dynamic Workflow features. +/// +/// Rather than overriding Claude Code's intentional `thinking: disabled` for +/// sub-agents, we respect that decision and remove the conflicting effort +/// parameters instead. `thinking: disabled` means "don't output thinking +/// blocks", which is the correct behavior for sub-agents that don't need +/// to display reasoning to the user. +/// +/// +pub fn normalize_deepseek_thinking_disabled_strip_effort( + body: &mut Value, + provider: &Provider, +) -> bool { + if !is_deepseek_official_anthropic_endpoint(provider) { + return false; + } + + let thinking_type = body + .get("thinking") + .and_then(|t| t.get("type")) + .and_then(|t| t.as_str()); + + if thinking_type != Some("disabled") { + return false; + } + + let mut changed = false; + + // Remove output_config.effort (Anthropic format) + if let Some(oc) = body + .get_mut("output_config") + .and_then(|v| v.as_object_mut()) + { + changed |= oc.remove("effort").is_some(); + // Clean up empty output_config + if oc.is_empty() { + body.as_object_mut().unwrap().remove("output_config"); + } + } + + // Remove reasoning_effort (OpenAI format, may be present in passthrough) + if body.get("reasoning_effort").is_some() { + body.as_object_mut().unwrap().remove("reasoning_effort"); + changed = true; + } + + changed +} + pub fn normalize_anthropic_messages_for_provider( body: &mut Value, provider: &Provider, @@ -154,77 +225,12 @@ pub fn normalize_anthropic_messages_for_provider( return false; } - let mut changed = normalize_anthropic_system_role_messages(body); - changed |= normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format); + let mut changed = + normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format); + changed |= normalize_deepseek_thinking_disabled_strip_effort(body, provider); changed } -fn normalize_anthropic_system_role_messages(body: &mut Value) -> bool { - let mut system_parts = Vec::new(); - let changed = { - let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { - return false; - }; - - let original_len = messages.len(); - let mut kept_messages = Vec::with_capacity(messages.len()); - for message in std::mem::take(messages) { - if message.get("role").and_then(Value::as_str) == Some("system") { - if let Some(content) = message.get("content") { - append_anthropic_system_parts(content, &mut system_parts); - } - } else { - kept_messages.push(message); - } - } - - let changed = kept_messages.len() != original_len; - *messages = kept_messages; - changed - }; - - if !changed || system_parts.is_empty() { - return changed; - } - - let mut merged_parts = Vec::new(); - if let Some(existing) = body.get("system") { - append_anthropic_system_parts(existing, &mut merged_parts); - } - merged_parts.extend(system_parts); - - if !merged_parts.is_empty() { - body["system"] = Value::Array(merged_parts); - } - - true -} - -fn append_anthropic_system_parts(content: &Value, parts: &mut Vec) { - match content { - Value::String(text) if !text.trim().is_empty() => { - parts.push(json!({ - "type": "text", - "text": text - })); - } - Value::Array(items) => { - for item in items { - append_anthropic_system_parts(item, parts); - } - } - Value::Object(obj) - if obj - .get("text") - .and_then(Value::as_str) - .is_some_and(|text| !text.trim().is_empty()) => - { - parts.push(Value::Object(obj.clone())); - } - _ => {} - } -} - fn normalize_anthropic_tool_thinking_history(body: &mut Value) -> bool { let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return false; @@ -2117,7 +2123,10 @@ mod tests { } #[test] - fn test_anthropic_system_role_messages_move_to_top_level_system() { + fn test_anthropic_messages_no_longer_hoists_system_role_messages() { + // After reverting #3775, role=system messages are left in `messages[]` + // (DeepSeek's endpoint accepts them natively) and the top-level `system` + // field is untouched, preserving the request prefix. let provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", @@ -2139,15 +2148,13 @@ mod tests { let changed = normalize_anthropic_messages_for_provider(&mut body, &provider, "anthropic"); - assert!(changed); + assert!(!changed); let messages = body["messages"].as_array().unwrap(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0]["role"], "user"); - - let system = body["system"].as_array().unwrap(); - assert_eq!(system[0]["text"], "Existing top-level system."); - assert_eq!(system[1]["text"], "Message system one."); - assert_eq!(system[2]["text"], "Message system two."); + assert_eq!(messages.len(), 3); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(messages[2]["role"], "system"); + assert_eq!(body["system"], "Existing top-level system."); } #[test] @@ -2300,4 +2307,245 @@ mod tests { assert!(!changed); assert_eq!(body, original); } + + // ==================== normalize_deepseek_thinking_disabled_strip_effort 测试 ==================== + + fn deepseek_official_provider() -> Provider { + create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_API_KEY": "test-key" + } + })) + } + + #[test] + fn test_deepseek_official_strips_output_config_effort() { + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "disabled" }, + "output_config": { "effort": "max" }, + "max_tokens": 100000 + }); + + let changed = normalize_deepseek_thinking_disabled_strip_effort( + &mut body, + &deepseek_official_provider(), + ); + + assert!(changed); + assert_eq!(body["thinking"]["type"], "disabled"); + assert!(body.get("output_config").is_none()); + } + + #[test] + fn test_deepseek_official_strips_reasoning_effort() { + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "disabled" }, + "reasoning_effort": "high", + "max_tokens": 100000 + }); + + let changed = normalize_deepseek_thinking_disabled_strip_effort( + &mut body, + &deepseek_official_provider(), + ); + + assert!(changed); + assert_eq!(body["thinking"]["type"], "disabled"); + assert!(body.get("reasoning_effort").is_none()); + } + + #[test] + fn test_deepseek_official_strips_both_effort_fields() { + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "disabled" }, + "output_config": { "effort": "max" }, + "reasoning_effort": "high", + "max_tokens": 100000 + }); + + let changed = normalize_deepseek_thinking_disabled_strip_effort( + &mut body, + &deepseek_official_provider(), + ); + + assert!(changed); + assert_eq!(body["thinking"]["type"], "disabled"); + assert!(body.get("output_config").is_none()); + assert!(body.get("reasoning_effort").is_none()); + } + + #[test] + fn test_deepseek_official_no_effort_no_change() { + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "disabled" }, + "max_tokens": 100000 + }); + let original = body.clone(); + + let changed = normalize_deepseek_thinking_disabled_strip_effort( + &mut body, + &deepseek_official_provider(), + ); + + assert!(!changed); + assert_eq!(body, original); + } + + #[test] + fn test_deepseek_official_preserves_output_config_other_fields() { + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "disabled" }, + "output_config": { "effort": "max", "temperature": 0.5 }, + "max_tokens": 100000 + }); + + let changed = normalize_deepseek_thinking_disabled_strip_effort( + &mut body, + &deepseek_official_provider(), + ); + + assert!(changed); + assert_eq!(body["output_config"]["temperature"], 0.5); + assert!(body["output_config"].get("effort").is_none()); + } + + #[test] + fn test_deepseek_official_non_disabled_not_modified() { + let cases = vec![ + ( + "enabled", + json!({ "type": "enabled", "budget_tokens": 16000 }), + ), + ("adaptive", json!({ "type": "adaptive" })), + ]; + + for (label, thinking_value) in cases { + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": thinking_value, + "output_config": { "effort": "max" }, + "max_tokens": 100000 + }); + let original = body.clone(); + + let changed = normalize_deepseek_thinking_disabled_strip_effort( + &mut body, + &deepseek_official_provider(), + ); + + assert!(!changed, "should not modify thinking.type={label}"); + assert_eq!(body, original); + } + + // missing thinking field entirely + let mut body = json!({ + "model": "deepseek-v4-pro", + "output_config": { "effort": "max" }, + "max_tokens": 100000 + }); + let original = body.clone(); + assert!(!normalize_deepseek_thinking_disabled_strip_effort( + &mut body, + &deepseek_official_provider() + )); + assert_eq!(body, original); + } + + #[test] + fn test_deepseek_official_url_with_trailing_slash() { + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/", + "ANTHROPIC_API_KEY": "test-key" + } + })); + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "disabled" }, + "output_config": { "effort": "max" }, + "max_tokens": 100000 + }); + + let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider); + + assert!(changed); + assert!(body.get("output_config").is_none()); + } + + #[test] + fn test_deepseek_official_detected_via_base_url_fallback() { + let provider = create_provider(json!({ + "base_url": "https://api.deepseek.com/anthropic", + "ANTHROPIC_API_KEY": "test-key" + })); + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "disabled" }, + "reasoning_effort": "high", + "max_tokens": 100000 + }); + + let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider); + + assert!(changed); + assert!(body.get("reasoning_effort").is_none()); + } + + #[test] + fn test_non_deepseek_endpoint_not_modified() { + let providers = vec![ + create_provider(json!({ + "env": { "ANTHROPIC_BASE_URL": "https://other-api.com/anthropic", "ANTHROPIC_API_KEY": "test-key" } + })), + create_provider(json!({ + "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "test-key" } + })), + ]; + + for provider in providers { + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "disabled" }, + "output_config": { "effort": "max" }, + "max_tokens": 100000 + }); + let original = body.clone(); + + let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider); + + assert!( + !changed, + "should not modify for {}", + provider.settings_config["env"]["ANTHROPIC_BASE_URL"] + ); + assert_eq!(body, original); + } + } + + #[test] + fn test_normalize_messages_pipeline_strips_effort_for_deepseek() { + let mut body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "disabled" }, + "output_config": { "effort": "max" }, + "max_tokens": 100000, + "messages": [{ "role": "user", "content": "hello" }] + }); + + let changed = normalize_anthropic_messages_for_provider( + &mut body, + &deepseek_official_provider(), + "anthropic", + ); + + assert!(changed); + assert_eq!(body["thinking"]["type"], "disabled"); + assert!(body.get("output_config").is_none()); + } } diff --git a/src-tauri/src/proxy/providers/codex_chat_history.rs b/src-tauri/src/proxy/providers/codex_chat_history.rs index d14b52754..783d2be17 100644 --- a/src-tauri/src/proxy/providers/codex_chat_history.rs +++ b/src-tauri/src/proxy/providers/codex_chat_history.rs @@ -150,7 +150,7 @@ impl CodexChatHistoryStore { Some(item_type) if is_call_item_type(item_type) => { if let Some(call_id) = response_item_call_id(&item) { if let Some(cached) = lookup.call(&call_id) { - if enrich_call_item_reasoning(&mut item, cached) { + if enrich_call_item_from_cache(&mut item, cached) { enriched += 1; } } @@ -466,17 +466,26 @@ fn is_call_output_item_type(item_type: &str) -> bool { ) } -fn enrich_call_item_reasoning(item: &mut Value, cached: &Value) -> bool { +fn enrich_call_item_from_cache(item: &mut Value, cached: &Value) -> bool { let mut changed = false; - for key in ["reasoning_content", "reasoning"] { + for key in [ + "name", + "namespace", + "arguments", + "input", + "status", + "execution", + "reasoning_content", + "reasoning", + ] { if item.get(key).is_some_and(|value| !is_empty_value(value)) { continue; } - let Some(reasoning) = cached.get(key).filter(|value| !is_empty_value(value)) else { + let Some(value) = cached.get(key).filter(|value| !is_empty_value(value)) else { continue; }; if let Some(object) = item.as_object_mut() { - object.insert(key.to_string(), reasoning.clone()); + object.insert(key.to_string(), value.clone()); changed = true; } } @@ -675,6 +684,48 @@ mod tests { assert_eq!(input.len(), 2); } + #[tokio::test] + async fn enriches_existing_function_call_missing_name_and_arguments() { + let history = CodexChatHistoryStore::default(); + history + .record_response(&json!({ + "id": "resp_1", + "output": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "read_file", + "arguments": "{\"path\":\"README.md\"}", + "reasoning_content": "Need to inspect the file." + } + ] + })) + .await; + + let mut request = json!({ + "previous_response_id": "resp_1", + "input": [ + { + "type": "function_call", + "call_id": "call_1" + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "ok" + } + ] + }); + + assert_eq!(history.enrich_request(&mut request).await, 1); + let input = request["input"].as_array().unwrap(); + assert_eq!(input[0]["type"], "function_call"); + assert_eq!(input[0]["name"], "read_file"); + assert_eq!(input[0]["arguments"], "{\"path\":\"README.md\"}"); + assert_eq!(input[0]["reasoning_content"], "Need to inspect the file."); + assert_eq!(input[1]["type"], "function_call_output"); + } + #[tokio::test] async fn restores_parallel_tool_calls_as_one_assistant_group() { let history = CodexChatHistoryStore::default(); diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index 7039c1b27..a5326ab13 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -429,8 +429,10 @@ impl ChatToResponsesState { if let Some(id) = id_delta { state.call_id = id; } - if let Some(name) = name_delta { - state.name = name; + if let Some(ref name) = name_delta { + if !name.is_empty() { + state.name.clone_from(name); + } } if !args_delta.is_empty() { state.arguments.push_str(&args_delta); @@ -442,7 +444,7 @@ impl ChatToResponsesState { } } - if !state.added && (!state.call_id.is_empty() || !state.name.is_empty()) { + if !state.added && !state.call_id.is_empty() && !state.name.is_empty() { should_add = true; pending_arguments = state.arguments.clone(); } else if state.added { @@ -464,9 +466,6 @@ impl ChatToResponsesState { if state.call_id.is_empty() { state.call_id = format!("call_{chat_index}"); } - if state.name.is_empty() { - state.name = "unknown_tool".to_string(); - } state.output_index = Some(assigned); let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name); state.item_id = response_tool_call_item_id_from_chat_name( @@ -699,6 +698,21 @@ impl ChatToResponsesState { continue; } + // Skip tool calls with missing names (defensive: some models generate + // tool call deltas without providing a valid function name) + let has_bad_name = self + .tools + .get(&key) + .map(|state| state.name.is_empty()) + .unwrap_or(true); + if has_bad_name { + if let Some(state) = self.tools.get_mut(&key) { + state.done = true; + } + log::warn!("[Codex] Skipping streaming tool call with missing name"); + continue; + } + if self .tools .get(&key) @@ -713,9 +727,6 @@ impl ChatToResponsesState { if state.call_id.is_empty() { state.call_id = format!("call_{key}"); } - if state.name.is_empty() { - state.name = "unknown_tool".to_string(); - } state.output_index = Some(assigned); state.item_id = response_tool_call_item_id_from_chat_name( &state.call_id, diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index d43809f0f..b8bd0e1c8 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -1398,6 +1398,14 @@ fn chat_tool_calls_to_response_output_items( if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) { for (index, tool_call) in tool_calls.iter().enumerate() { + // Skip tool calls with missing function names (defensive: some models + // 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.is_empty() { + log::warn!("[Codex] Skipping tool call with missing name"); + continue; + } output.push(chat_tool_call_to_response_item( tool_call, index, @@ -1406,11 +1414,11 @@ fn chat_tool_calls_to_response_output_items( )); } } else if let Some(function_call) = message.get("function_call") { - output.push(chat_legacy_function_call_to_response_item( - function_call, - reasoning, - tool_context, - )); + if let Some(item) = + chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context) + { + output.push(item); + } } output @@ -1448,7 +1456,7 @@ fn chat_legacy_function_call_to_response_item( function_call: &Value, reasoning: Option<&str>, tool_context: &CodexToolContext, -) -> Value { +) -> Option { let call_id = function_call .get("id") .and_then(|v| v.as_str()) @@ -1458,10 +1466,18 @@ fn chat_legacy_function_call_to_response_item( .get("name") .and_then(|v| v.as_str()) .unwrap_or(""); + + // Skip legacy function calls with missing names (defensive: some models + // 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; + } + let arguments = canonicalize_tool_arguments(function_call.get("arguments")); let item_id = response_tool_call_item_id_from_chat_name(call_id, name, tool_context); - response_tool_call_item_from_chat_name( + Some(response_tool_call_item_from_chat_name( &item_id, "completed", call_id, @@ -1469,7 +1485,7 @@ fn chat_legacy_function_call_to_response_item( &arguments, reasoning, tool_context, - ) + )) } pub(crate) fn response_tool_call_item_id_from_chat_name( diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 25c2f009b..19098db67 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -4,7 +4,7 @@ //! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。 use super::subscription::{ - CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT, + CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_MONTHLY, TIER_WEEKLY_LIMIT, }; use std::time::{SystemTime, UNIX_EPOCH}; @@ -17,6 +17,9 @@ enum CodingPlanProvider { MiniMaxCn, MiniMaxEn, ZenMux, + /// 火山方舟 Agent Plan / Coding Plan(base_url 形如 + /// `https://ark.cn-beijing.volces.com/api/coding[/v3]`)。 + Volcengine, } fn detect_provider(base_url: &str) -> Option { @@ -33,6 +36,10 @@ fn detect_provider(base_url: &str) -> Option { Some(CodingPlanProvider::MiniMaxEn) } else if url.contains("zenmux") { Some(CodingPlanProvider::ZenMux) + } else if url.contains("volces.com/api/coding") { + // 仅匹配 Coding/Agent Plan 入口;DouBaoSeed 按量付费走 /api/v3 与 + // /api/compatible,没有套餐额度,不在此命中。 + Some(CodingPlanProvider::Volcengine) } else { None } @@ -59,6 +66,10 @@ fn extract_reset_time(value: &serde_json::Value) -> Option { return Some(s.to_string()); } if let Some(n) = value.as_i64() { + // 0/负时间戳(如火山 session 无活跃窗口回 -1)视为无重置时间 + if n <= 0 { + return None; + } // 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12 let ms = if n < 1_000_000_000_000 { n * 1000 } else { n }; return millis_to_iso8601(ms); @@ -652,43 +663,517 @@ fn parse_minimax_tiers(body: &serde_json::Value) -> Vec { tiers } +// ── 火山方舟 Agent Plan / Coding Plan ─────────────────────── +// +// 与 Kimi/MiniMax(数据面 Bearer 余额接口)不同,火山用量接口是**控制面 +// OpenAPI**:统一网关 `open.volcengineapi.com`(**不是**数据面推理域名 +// `ark.cn-beijing.volces.com`),形如 +// `POST https://open.volcengineapi.com/?Action=...&Version=2024-01-01&Region=cn-beijing`, +// **强制火山引擎签名 V4(AK/SK)**——实测复用推理 Bearer Key 会被网关以 +// `400 InvalidAuthorization` 拒绝(格式层拒绝,非权限问题)。因此用户需在用量查询 +// 里另填火山账号的 AccessKey ID + Secret(与推理 Key 是两套凭据)。两个 plan 用 +// 同一份 AK/SK,故鉴权类错误直接停、不再试另一个 plan。 +// +// 自动探测:先调 `GetAFPUsage`(Agent Plan,回绝对额度 Quota/Used),未订阅再调 +// `GetCodingPlanUsage`(Coding Plan,回百分比)。 + +/// 控制面 OpenAPI 统一网关(区别于数据面推理域名 ark.cn-beijing.volces.com)。 +const VOLCENGINE_OPENAPI_HOST: &str = "open.volcengineapi.com"; +const VOLCENGINE_API_VERSION: &str = "2024-01-01"; +/// ark 控制面 OpenAPI 的默认 Region(Agent/Coding Plan 目前在 cn-beijing)。 +const VOLCENGINE_DEFAULT_REGION: &str = "cn-beijing"; + +/// 单次 OpenAPI 调用的归类结果。 +enum VolcCall { + /// 2xx 且 JSON 可解析、无 OpenAPI 级错误(业务 Result 仍可能为空=未订阅)。 + Body(serde_json::Value), + /// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan + /// 共用凭据,命中即停。 + Auth(String), + /// 网络 / 非鉴权 HTTP 错误 / 解析失败——记录后可继续尝试另一个 plan。 + Soft(String), +} + +/// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如 +/// `ark.cn-beijing.volces.com` → `cn-beijing`);无法识别时回落 cn-beijing。 +/// 控制面 Host 是固定网关(`VOLCENGINE_OPENAPI_HOST`),不随 base_url 变化。 +fn volcengine_region(base_url: &str) -> String { + let host = base_url + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(base_url) + .split('/') + .next() + .unwrap_or(""); + host.split('.') + .find(|p| p.starts_with("cn-") || p.starts_with("ap-")) + .map(|p| p.to_string()) + .unwrap_or_else(|| VOLCENGINE_DEFAULT_REGION.to_string()) +} + +/// 判断 OpenAPI 错误码是否属于鉴权类(需要硬停并提示换 AK/SK)。 +fn volcengine_is_auth_error_code(code: &str) -> bool { + let c = code.to_lowercase(); + c.contains("auth") + || c.contains("signature") + || c.contains("accessdenied") + || c.contains("denied") + || c.contains("unauthorized") + || c.contains("forbidden") + || c.contains("credential") + || c.contains("token") +} + +/// 提取火山 OpenAPI 响应里的 `ResponseMetadata.Error`(或顶层 `Error`)。 +fn volcengine_response_error(body: &serde_json::Value) -> Option<(String, String)> { + let err = body + .get("ResponseMetadata") + .and_then(|m| m.get("Error")) + .or_else(|| body.get("Error"))?; + let code = err + .get("Code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let msg = err + .get("Message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if code.is_empty() && msg.is_empty() { + None + } else { + Some((code, msg)) + } +} + +/// 鉴权失败时的引导文案,附加在错误后。 +const VOLCENGINE_AKSK_HINT: &str = + "Check the AccessKey ID / Secret are correct and the account has Ark usage-query (OpenAPI) permission."; + +// ── 火山引擎签名 V4(AK/SK)───────────────────────────────── +// +// 算法是 AWS SigV4 的火山变体(对照官方 volc-openapi-demos/signature/java/Sign.java)。 +// **两处致命差异,照搬 s3.rs 的标准 SigV4 会签名失败**: +// 1. canonical headers 与 SignedHeaders 用**固定顺序** +// `host;x-date;x-content-sha256;content-type`(**不按字母序**,s3.rs 是字母序); +// 2. algorithm 串 `HMAC-SHA256`(无 `AWS4` 前缀)、credential scope 结尾 `request` +// (非 `aws4_request`)、签名密钥 `kDate=HMAC(SK, date)`(SK 不加 `AWS4` 前缀)。 +// canonical query 仍按 key 字母序(与标准 SigV4 一致);service=`ark`、POST、空 body。 + +const VOLCENGINE_SERVICE: &str = "ark"; +const VOLCENGINE_CONTENT_TYPE: &str = "application/json; charset=utf-8"; +const VOLCENGINE_SIGNED_HEADERS: &str = "host;x-date;x-content-sha256;content-type"; + +fn volc_hmac_sha256(key: &[u8], data: &[u8]) -> Vec { + use hmac::{Hmac, Mac}; + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +fn volc_sha256_hex(data: &[u8]) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(data)) +} + +/// RFC3986 unreserved 之外全部按 `%XX` 编码(用于 canonical query string)。 +fn volc_uri_encode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => { + use std::fmt::Write; + let _ = write!(out, "%{byte:02X}"); + } + } + } + out +} + +/// 构造按 key 字母序排序、逐段 URL 编码的 canonical query string。 +/// 同一份字符串既用于签名也用于实际请求 URL,保证两者完全一致。 +fn volcengine_canonical_query(action: &str, region: &str) -> String { + let mut pairs = [ + ("Action", action), + ("Region", region), + ("Version", VOLCENGINE_API_VERSION), + ]; + pairs.sort_by(|a, b| a.0.cmp(b.0)); + pairs + .iter() + .map(|(k, v)| format!("{}={}", volc_uri_encode(k), volc_uri_encode(v))) + .collect::>() + .join("&") +} + +/// 生成火山引擎签名 V4 的鉴权头,返回 `(Authorization, X-Date, X-Content-Sha256)`, +/// 三者都要塞进请求头;`canonical_query` 必须与实际请求 URL 的 query 完全一致。 +/// `now` 作参数传入便于写确定性单测。 +fn volcengine_sign( + access_key_id: &str, + secret_access_key: &str, + region: &str, + canonical_query: &str, + body: &[u8], + now: chrono::DateTime, +) -> (String, String, String) { + let x_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let short_date = now.format("%Y%m%d").to_string(); + let x_content_sha256 = volc_sha256_hex(body); + + // 固定顺序 canonical headers(火山特有,**不排序**)。 + let canonical_headers = format!( + "host:{VOLCENGINE_OPENAPI_HOST}\nx-date:{x_date}\nx-content-sha256:{x_content_sha256}\ncontent-type:{VOLCENGINE_CONTENT_TYPE}\n" + ); + let canonical_request = format!( + "POST\n/\n{canonical_query}\n{canonical_headers}\n{VOLCENGINE_SIGNED_HEADERS}\n{x_content_sha256}" + ); + + let credential_scope = format!("{short_date}/{region}/{VOLCENGINE_SERVICE}/request"); + let string_to_sign = format!( + "HMAC-SHA256\n{x_date}\n{credential_scope}\n{}", + volc_sha256_hex(canonical_request.as_bytes()) + ); + + // 签名密钥派生:kDate=HMAC(SK, date)(SK **不加** AWS4 前缀),终止串 `request`。 + let k_date = volc_hmac_sha256(secret_access_key.as_bytes(), short_date.as_bytes()); + let k_region = volc_hmac_sha256(&k_date, region.as_bytes()); + let k_service = volc_hmac_sha256(&k_region, VOLCENGINE_SERVICE.as_bytes()); + let k_signing = volc_hmac_sha256(&k_service, b"request"); + let signature: String = volc_hmac_sha256(&k_signing, string_to_sign.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + + let authorization = format!( + "HMAC-SHA256 Credential={access_key_id}/{credential_scope}, SignedHeaders={VOLCENGINE_SIGNED_HEADERS}, Signature={signature}" + ); + (authorization, x_date, x_content_sha256) +} + +async fn volcengine_openapi_call( + region: &str, + access_key_id: &str, + secret_access_key: &str, + action: &str, +) -> VolcCall { + let client = crate::proxy::http_client::get(); + // canonical query 同时用于签名与实际 URL,确保两者逐字一致(否则签名不匹配)。 + let canonical_query = volcengine_canonical_query(action, region); + let url = format!("https://{VOLCENGINE_OPENAPI_HOST}/?{canonical_query}"); + let body: &[u8] = b""; + let (authorization, x_date, x_content_sha256) = volcengine_sign( + access_key_id, + secret_access_key, + region, + &canonical_query, + body, + chrono::Utc::now(), + ); + + let resp = client + .post(&url) + .header("X-Date", x_date) + .header("X-Content-Sha256", x_content_sha256) + .header("Content-Type", VOLCENGINE_CONTENT_TYPE) + .header("Authorization", authorization) + .body(body.to_vec()) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return VolcCall::Soft(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return VolcCall::Auth(format!( + "Authentication failed (HTTP {status}). {VOLCENGINE_AKSK_HINT}" + )); + } + if !status.is_success() { + // 火山 OpenAPI 网关对签名/凭据类错误常返 4xx(多为 HTTP 400)并携带与 200 + // 路径相同的 ResponseMetadata.Error 信封,而非 401/403。这里也解析信封,让 + // Bearer 被拒时仍能给出 AK/SK 引导并标记凭据失效,而不是当成普通 API 错误。 + let raw = resp.text().await.unwrap_or_default(); + if let Ok(body) = serde_json::from_str::(&raw) { + if let Some((code, msg)) = volcengine_response_error(&body) { + if volcengine_is_auth_error_code(&code) { + return VolcCall::Auth(format!( + "Authentication failed (HTTP {status}, {code}): {msg}. {VOLCENGINE_AKSK_HINT}" + )); + } + return VolcCall::Soft(format!("API error (HTTP {status}, {code}): {msg}")); + } + } + return VolcCall::Soft(format!("API error (HTTP {status}): {raw}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")), + }; + + // 火山 OpenAPI 业务错误常以 200 + ResponseMetadata.Error 返回。 + if let Some((code, msg)) = volcengine_response_error(&body) { + if volcengine_is_auth_error_code(&code) { + return VolcCall::Auth(format!( + "Authentication failed ({code}): {msg}. {VOLCENGINE_AKSK_HINT}" + )); + } + return VolcCall::Soft(format!("API error ({code}): {msg}")); + } + + VolcCall::Body(body) +} + +/// 解析 `GetAFPUsage` 的 `Result` 为 tier 列表。 +/// +/// 展示 5h / 周 / 月三个窗口(与控制台一致);`AFPDaily` 被官方控制台隐藏 +/// (其 Quota 常高于周上限,属历史默认值而非强制限额),故跳过。 +/// `Quota`/`Used` 是绝对 AFP 值,已用百分比 = Used/Quota×100;`Quota<=0` 视为 +/// 该窗口未订阅/未启用,跳过——也用于把"已鉴权但无 Agent Plan"识别为空结果, +/// 从而回落到 Coding Plan 探测。 +fn parse_afp_tiers(result: &serde_json::Value) -> Vec { + let mut tiers = Vec::new(); + for (key, name) in [ + ("AFPFiveHour", TIER_FIVE_HOUR), + ("AFPWeekly", TIER_WEEKLY_LIMIT), + ("AFPMonthly", TIER_MONTHLY), + ] { + let Some(win) = result.get(key) else { continue }; + let quota = win.get("Quota").and_then(parse_f64).unwrap_or(0.0); + if quota <= 0.0 { + continue; + } + let used = win.get("Used").and_then(parse_f64).unwrap_or(0.0); + // 已用百分比;不做范围裁剪,与 parse_zhipu_token_tiers/parse_minimax_tiers + // 的约定一致(下游渲染层负责显示策略)。 + let utilization = used / quota * 100.0; + let resets_at = win.get("ResetTime").and_then(extract_reset_time); + tiers.push(QuotaTier { + name: name.to_string(), + utilization, + resets_at, + used_value_usd: None, + max_value_usd: None, + }); + } + tiers +} + +/// 把 `GetCodingPlanUsage` 的 window 标签归一到 tier 名。 +fn volcengine_coding_window(label: &str) -> Option<&'static str> { + match label.to_lowercase().as_str() { + "session" | "5h" | "fivehour" | "five_hour" | "rolling_5h" => Some(TIER_FIVE_HOUR), + "weekly" | "week" | "7d" => Some(TIER_WEEKLY_LIMIT), + "monthly" | "month" => Some(TIER_MONTHLY), + _ => None, + } +} + +/// 解析 `GetCodingPlanUsage` 的 `Result` 为 tier 列表(防御式)。 +/// +/// 该接口官方文档未给出逐字段规格,依据官方 ark-cli 描述:回 session/weekly/ +/// monthly 窗口、**只给百分比**(已用)、重置时间是秒级。这里宽松匹配 +/// `QuotaUsage`/`Usages`/`Details` 数组及多种字段名,命中即用、未命中跳过。 +fn parse_coding_plan_tiers(result: &serde_json::Value) -> Vec { + let mut tiers = Vec::new(); + let arr = result + .get("QuotaUsage") + .and_then(|v| v.as_array()) + .or_else(|| result.get("Usages").and_then(|v| v.as_array())) + .or_else(|| result.get("Details").and_then(|v| v.as_array())); + let Some(arr) = arr else { return tiers }; + + for item in arr { + // 真实字段是 `Level`(实测 2026-06-21:session/weekly/monthly);其余作防御式 fallback。 + let label = item + .get("Level") + .and_then(|v| v.as_str()) + .or_else(|| item.get("Type").and_then(|v| v.as_str())) + .or_else(|| item.get("Period").and_then(|v| v.as_str())) + .or_else(|| item.get("Label").and_then(|v| v.as_str())) + .or_else(|| item.get("Window").and_then(|v| v.as_str())) + .unwrap_or(""); + let Some(name) = volcengine_coding_window(label) else { + continue; + }; + let utilization = item + .get("Percent") + .and_then(parse_f64) + .or_else(|| item.get("UsedPercent").and_then(parse_f64)) + .or_else(|| item.get("UsagePercent").and_then(parse_f64)) + .unwrap_or(0.0); + // 兼容秒/毫秒/字符串(extract_reset_time 内部已区分秒与毫秒)。 + let resets_at = item + .get("ResetTime") + .or_else(|| item.get("ResetTimestamp")) + .and_then(extract_reset_time); + tiers.push(QuotaTier { + name: name.to_string(), + utilization, + resets_at, + used_value_usd: None, + max_value_usd: None, + }); + } + tiers +} + +fn volcengine_success(tiers: Vec, plan: Option) -> SubscriptionQuota { + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: plan, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + +fn volcengine_auth_error(detail: String) -> SubscriptionQuota { + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Expired, + credential_message: Some("Invalid API key".to_string()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(detail), + queried_at: Some(now_millis()), + } +} + +async fn query_volcengine( + base_url: &str, + access_key_id: &str, + secret_access_key: &str, +) -> SubscriptionQuota { + let region = volcengine_region(base_url); + let mut soft_errors: Vec = Vec::new(); + // 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅" + // 与"字段名/包裹层猜错")。签名若不通会走 Auth/Soft 分支,到不了这里。 + let mut empty_responses: Vec = Vec::new(); + let summarize = |action: &str, body: &serde_json::Value| -> String { + let raw: String = body.to_string().chars().take(700).collect(); + format!("{action}={raw}") + }; + + // 1) Agent Plan:GetAFPUsage + match volcengine_openapi_call(®ion, access_key_id, secret_access_key, "GetAFPUsage").await { + VolcCall::Auth(detail) => return volcengine_auth_error(detail), + VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")), + VolcCall::Body(body) => { + let result = body.get("Result").unwrap_or(&body); + let tiers = parse_afp_tiers(result); + if !tiers.is_empty() { + let plan = result + .get("PlanType") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| format!("Agent Plan {s}")); + return volcengine_success(tiers, plan); + } + empty_responses.push(summarize("GetAFPUsage", &body)); + } + } + + // 2) Coding Plan:GetCodingPlanUsage + match volcengine_openapi_call( + ®ion, + access_key_id, + secret_access_key, + "GetCodingPlanUsage", + ) + .await + { + VolcCall::Auth(detail) => return volcengine_auth_error(detail), + VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")), + VolcCall::Body(body) => { + let result = body.get("Result").unwrap_or(&body); + let tiers = parse_coding_plan_tiers(result); + if !tiers.is_empty() { + return volcengine_success(tiers, Some("Coding Plan".to_string())); + } + empty_responses.push(summarize("GetCodingPlanUsage", &body)); + } + } + + if !soft_errors.is_empty() { + make_error(soft_errors.join("; ")) + } else if !empty_responses.is_empty() { + // 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应, + // 便于核对真实字段名/包裹层,或确认确实未订阅。 + make_error(format!( + "No active subscription found (signature OK). Raw: {}", + empty_responses.join(" || ") + )) + } else { + make_error( + "No active Agent Plan or Coding Plan subscription found for this credential" + .to_string(), + ) + } +} + // ── 公开入口 ──────────────────────────────────────────────── +/// 构造"凭据缺失 / 域名未命中"的失败结果(NotFound 状态 + 明确错误文案)。 +fn coding_plan_not_found(error: &str) -> SubscriptionQuota { + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::NotFound, + credential_message: None, + success: false, + tiers: vec![], + extra_usage: None, + error: Some(error.to_string()), + queried_at: None, + } +} + pub async fn get_coding_plan_quota( base_url: &str, api_key: &str, + access_key_id: Option<&str>, + secret_access_key: Option<&str>, ) -> Result { - if api_key.trim().is_empty() { - return Ok(SubscriptionQuota { - tool: "coding_plan".to_string(), - credential_status: CredentialStatus::NotFound, - credential_message: None, - success: false, - tiers: vec![], - extra_usage: None, - // 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败 - error: Some("API key is empty".to_string()), - queried_at: None, - }); - } - let provider = match detect_provider(base_url) { Some(p) => p, - None => { - return Ok(SubscriptionQuota { - tool: "coding_plan".to_string(), - credential_status: CredentialStatus::NotFound, - credential_message: None, - success: false, - tiers: vec![], - extra_usage: None, - // 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败 - error: Some("Unknown coding plan provider".to_string()), - queried_at: None, - }); - } + // 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败 + None => return Ok(coding_plan_not_found("Unknown coding plan provider")), }; + // 火山方舟走控制面 AK/SK 签名(区别于其他供应商的数据面 Bearer api_key),凭据 + // 校验与查询路径都不同,单独分支提前处理。 + if let CodingPlanProvider::Volcengine = provider { + let ak = access_key_id.unwrap_or("").trim(); + let sk = secret_access_key.unwrap_or("").trim(); + if ak.is_empty() || sk.is_empty() { + return Ok(coding_plan_not_found( + "Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)", + )); + } + return Ok(query_volcengine(base_url, ak, sk).await); + } + + // 其余供应商:数据面 Bearer api_key。 + // 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败 + if api_key.trim().is_empty() { + return Ok(coding_plan_not_found("API key is empty")); + } + let quota = match provider { CodingPlanProvider::Kimi => query_kimi(api_key).await, CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => { @@ -697,6 +1182,10 @@ pub async fn get_coding_plan_quota( CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await, CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await, CodingPlanProvider::ZenMux => query_zenmux(base_url, api_key).await, + // 火山已在上面的 AK/SK 分支提前返回,此处不可达。 + CodingPlanProvider::Volcengine => { + unreachable!("volcengine handled via AK/SK branch above") + } }; Ok(quota) @@ -705,7 +1194,9 @@ pub async fn get_coding_plan_quota( #[cfg(test)] mod tests { use super::{ - parse_minimax_tiers, parse_zhipu_token_tiers, zhipu_quota_base, TIER_FIVE_HOUR, + parse_afp_tiers, parse_coding_plan_tiers, parse_minimax_tiers, parse_zhipu_token_tiers, + volcengine_canonical_query, volcengine_is_auth_error_code, volcengine_region, + volcengine_response_error, volcengine_sign, zhipu_quota_base, TIER_FIVE_HOUR, TIER_MONTHLY, TIER_WEEKLY_LIMIT, }; use serde_json::json; @@ -1135,4 +1626,184 @@ mod tests { "https://open.bigmodel.cn" ); } + + // ── 火山方舟 Agent Plan / Coding Plan ── + + #[test] + fn volcengine_afp_three_windows_from_official_example() { + // 官方文档 GetAFPUsage 返回示例(逐字):5h 25% / weekly 30% / monthly + // 42.525%;AFPDaily 被控制台隐藏,应跳过。 + let result = json!({ + "PlanType": "Large", + "AFPFiveHour": { "Quota": 50.0, "Used": 12.5, "SubscribeTime": 1778788800000_i64, "ResetTime": 1778806800000_i64 }, + "AFPDaily": { "Quota": 100.0, "Used": 22.5, "SubscribeTime": 1778716800000_i64, "ResetTime": 1778803200000_i64 }, + "AFPWeekly": { "Quota": 500.0, "Used": 150.0, "SubscribeTime": 1778457600000_i64, "ResetTime": 1779062400000_i64 }, + "AFPMonthly": { "Quota": 2000.0, "Used": 850.5, "SubscribeTime": 1777939200000_i64, "ResetTime": 1780531200000_i64 } + }); + let tiers = parse_afp_tiers(&result); + assert_eq!(tiers.len(), 3, "daily 应被跳过,只剩 5h/周/月"); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert!((tiers[0].utilization - 25.0).abs() < 1e-9); + assert!(tiers[0].resets_at.is_some()); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert!((tiers[1].utilization - 30.0).abs() < 1e-9); + assert_eq!(tiers[2].name, TIER_MONTHLY); + assert!((tiers[2].utilization - 42.525).abs() < 1e-9); + assert!(tiers[2].resets_at.is_some()); + } + + #[test] + fn volcengine_afp_zero_quota_windows_treated_as_unbound() { + // 已鉴权但无 Agent Plan:窗口 Quota=0 → 空结果,调用方据此回落 Coding Plan。 + let result = json!({ + "PlanType": "", + "AFPFiveHour": { "Quota": 0.0, "Used": 0.0 }, + "AFPWeekly": { "Quota": 0.0, "Used": 0.0 }, + "AFPMonthly": { "Quota": 0.0, "Used": 0.0 } + }); + assert!(parse_afp_tiers(&result).is_empty()); + } + + #[test] + fn volcengine_afp_partial_windows_only_subscribed_ones() { + // 仅 5h 窗口有额度(缺周/月)→ 只产出一个 tier。 + let result = json!({ + "AFPFiveHour": { "Quota": 40.0, "Used": 10.0, "ResetTime": 1778806800000_i64 }, + "AFPWeekly": { "Quota": 0.0, "Used": 0.0 } + }); + let tiers = parse_afp_tiers(&result); + assert_eq!(tiers.len(), 1); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert!((tiers[0].utilization - 25.0).abs() < 1e-9); + } + + #[test] + fn volcengine_coding_plan_real_response_levels() { + // 真实 GetCodingPlanUsage 响应(用户实测 2026-06-21):字段名是 `Level`(非 `Type`), + // 仅百分比,秒级 ResetTimestamp;session 无活跃窗口回 -1 → 无重置时间。 + let result = json!({ + "Status": "Running", + "UpdateTimestamp": 1782053286_i64, + "QuotaUsage": [ + { "Level": "session", "Percent": 0.0, "ResetTimestamp": -1_i64 }, + { "Level": "weekly", "Percent": 1.672568, "ResetTimestamp": 1782057600_i64 }, + { "Level": "monthly", "Percent": 0.836284, "ResetTimestamp": 1784303999_i64 } + ] + }); + let tiers = parse_coding_plan_tiers(&result); + assert_eq!(tiers.len(), 3); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert!((tiers[0].utilization - 0.0).abs() < 1e-9); + assert!( + tiers[0].resets_at.is_none(), + "session ResetTimestamp=-1 应无重置时间" + ); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert!((tiers[1].utilization - 1.672568).abs() < 1e-6); + assert!(tiers[1].resets_at.is_some()); + assert_eq!(tiers[2].name, TIER_MONTHLY); + assert!((tiers[2].utilization - 0.836284).abs() < 1e-6); + } + + #[test] + fn volcengine_coding_plan_unknown_window_skipped_and_missing_array_empty() { + let result = json!({ + "QuotaUsage": [ + { "Level": "daily", "Percent": 9.0 }, + { "Level": "weekly", "Percent": 20.0 } + ] + }); + let tiers = parse_coding_plan_tiers(&result); + assert_eq!(tiers.len(), 1, "未知 daily 窗口跳过"); + assert_eq!(tiers[0].name, TIER_WEEKLY_LIMIT); + + assert!(parse_coding_plan_tiers(&json!({})).is_empty()); + } + + #[test] + fn volcengine_region_derivation() { + assert_eq!( + volcengine_region("https://ark.cn-beijing.volces.com/api/coding"), + "cn-beijing" + ); + // 其他 region 的数据面域名按段提取。 + assert_eq!( + volcengine_region("https://ark.cn-shanghai.volces.com/api/coding/v3"), + "cn-shanghai" + ); + // 无可识别 region 段时回落默认 cn-beijing。 + assert_eq!( + volcengine_region("https://example.com/api/coding"), + "cn-beijing" + ); + } + + #[test] + fn volcengine_canonical_query_is_sorted_and_encoded() { + // 按 key 字母序:Action < Region < Version;值含 `-` 属 unreserved,不编码。 + assert_eq!( + volcengine_canonical_query("GetAFPUsage", "cn-beijing"), + "Action=GetAFPUsage&Region=cn-beijing&Version=2024-01-01" + ); + } + + #[test] + fn volcengine_sign_structure_and_determinism() { + // 没有服务端金标准向量时,锁定签名的结构契约 + 确定性(足以抓住 header 顺序、 + // scope 后缀、algorithm 前缀、空 body hash 等实现错误)。真实正确性靠用户实测。 + let now = chrono::DateTime::parse_from_rfc3339("2024-06-21T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + let region = "cn-beijing"; + let query = volcengine_canonical_query("GetAFPUsage", region); + let (auth, x_date, x_content) = + volcengine_sign("AKLTtest", "secretkey", region, &query, b"", now); + + // 空 body 的 SHA-256(固定值),证明走的是空 body。 + assert_eq!( + x_content, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + // X-Date 形如 20240621T000000Z。 + assert_eq!(x_date, "20240621T000000Z"); + // Authorization 结构:算法无 AWS4 前缀、scope 结尾 ark/request、固定 SignedHeaders。 + assert!( + auth.starts_with("HMAC-SHA256 Credential=AKLTtest/20240621/cn-beijing/ark/request,"), + "unexpected credential/scope: {auth}" + ); + assert!( + auth.contains("SignedHeaders=host;x-date;x-content-sha256;content-type,"), + "unexpected signed headers: {auth}" + ); + // Signature 是 64 位十六进制。 + let sig = auth.rsplit("Signature=").next().unwrap(); + assert_eq!(sig.len(), 64); + assert!(sig.bytes().all(|b| b.is_ascii_hexdigit())); + + // 确定性:同输入同输出。 + let (auth2, _, _) = volcengine_sign("AKLTtest", "secretkey", region, &query, b"", now); + assert_eq!(auth, auth2); + } + + #[test] + fn volcengine_auth_error_code_detection_and_extraction() { + assert!(volcengine_is_auth_error_code("AccessDenied")); + assert!(volcengine_is_auth_error_code("SignatureDoesNotMatch")); + assert!(volcengine_is_auth_error_code("InvalidAuthorization")); + assert!(volcengine_is_auth_error_code("Unauthorized")); + assert!(!volcengine_is_auth_error_code("InvalidParameter.Action")); + assert!(!volcengine_is_auth_error_code("InternalError")); + + // ResponseMetadata.Error 抽取 + let body = json!({ + "ResponseMetadata": { "RequestId": "x", "Error": { "Code": "AccessDenied", "Message": "no permission" } } + }); + let (code, msg) = volcengine_response_error(&body).expect("应抽到 Error"); + assert_eq!(code, "AccessDenied"); + assert_eq!(msg, "no permission"); + + // 无 Error 时返回 None + let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} }); + assert!(volcengine_response_error(&ok_body).is_none()); + } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 94920c9ef..bcabe07df 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -465,6 +465,7 @@ base_url = "http://localhost:8080" db.update_proxy_config(ProxyConfig { live_takeover_active: true, + listen_port: 0, ..Default::default() }) .await @@ -493,7 +494,7 @@ base_url = "http://localhost:8080" ) .expect("seed taken-over live file"); - state + let proxy_info = state .proxy_service .start() .await @@ -546,7 +547,7 @@ base_url = "http://localhost:8080" live.get("env") .and_then(|env| env.get("ANTHROPIC_BASE_URL")) .and_then(|v| v.as_str()), - Some("http://127.0.0.1:15721"), + Some(format!("http://127.0.0.1:{}", proxy_info.port).as_str()), "proxy base URL should stay intact" ); assert!( diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index a1c38a763..d05a5dd06 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -5701,8 +5701,10 @@ command = "shared-command" ) .expect("set common config snippet"); - let mut proxy_config = ProxyConfig::default(); - proxy_config.listen_port = 0; + let proxy_config = ProxyConfig { + listen_port: 0, + ..Default::default() + }; db.update_proxy_config(proxy_config) .await .expect("set test proxy config"); @@ -5839,8 +5841,10 @@ requires_openai_auth = true let db = Arc::new(Database::memory().expect("init db")); let state = crate::store::AppState::new(db.clone()); - let mut proxy_config = ProxyConfig::default(); - proxy_config.listen_port = 0; + let proxy_config = ProxyConfig { + listen_port: 0, + ..Default::default() + }; db.update_proxy_config(proxy_config) .await .expect("set test proxy config"); diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 092100349..5528455e6 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -307,6 +307,11 @@ pub const TIER_SEVEN_DAY_SONNET: &str = "seven_day_sonnet"; /// 写入、tray 渲染、commands::provider 扁平化三处共用同一标识。 pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; +/// 月窗口 tier 名。火山方舟 Agent Plan / Coding Plan 有 5h / 周 / 月 三个展示 +/// 窗口(Kimi / MiniMax 只有 5h + 周),月窗口共用此标识;前端 `TIER_I18N_KEYS` +/// 映射到 `subscription.monthly`。 +pub const TIER_MONTHLY: &str = "monthly"; + /// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。 pub const TIER_GEMINI_PRO: &str = "gemini_pro"; pub const TIER_GEMINI_FLASH: &str = "gemini_flash"; diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index ea175f297..e336735d8 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -1732,23 +1732,20 @@ impl Database { OR cache_read_tokens > 0 OR cache_creation_tokens > 0)"; let mut logs = { - match only_model_id { - Some(model) => { - let sql = format!( - "{BASE_SQL} AND (model = ?1 OR request_model = ?1 OR pricing_model = ?1)" - ); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map([model], row_to_request_log_detail)?; - rows.collect::, _>>()? - } - None => { - let mut stmt = conn.prepare(BASE_SQL)?; - let rows = stmt.query_map([], row_to_request_log_detail)?; - rows.collect::, _>>()? - } - } + let mut stmt = conn.prepare(BASE_SQL)?; + let rows = stmt.query_map([], row_to_request_log_detail)?; + rows.collect::, _>>()? }; + // 精准回填的行筛选必须与查价层共用 candidates 归一化:SQL 精确匹配会漏掉 + // 以原始别名落库的行(如 openrouter/anthropic/claude-sonnet-4.5:free), + // 这些行查价时能归一化命中新定价,却在筛选层被挡掉,导致导入定价后 + // 历史成本要等下次全量回填才更新。误纳无害——查不到价的行会被跳过。 + if let Some(model_id) = only_model_id { + let target = model_pricing_candidates(model_id); + logs.retain(|log| log_pricing_scope_matches(log, &target)); + } + if logs.is_empty() { return Ok(0); } @@ -1966,6 +1963,30 @@ pub(crate) fn find_model_pricing_row( Ok(None) } +/// 精准回填的行筛选:log 的任一模型字段归一化后与目标模型的 candidates 相交, +/// 或可按查价层的前缀规则命中目标,即视为相关。镜像 find_model_pricing_row 的 +/// 匹配语义,宁可误纳(后续查价会兜底)不可漏筛。 +fn log_pricing_scope_matches(log: &RequestLogDetail, target_candidates: &[String]) -> bool { + [ + Some(log.model.as_str()), + log.request_model.as_deref(), + log.pricing_model.as_deref(), + ] + .into_iter() + .flatten() + .any(|field| { + model_pricing_candidates(field).iter().any(|candidate| { + target_candidates.iter().any(|target| { + target == candidate + || (should_try_pricing_prefix_match(candidate) + && target + .strip_prefix(candidate.as_str()) + .is_some_and(|rest| rest.starts_with('-'))) + }) + }) + }) +} + pub(crate) fn is_placeholder_pricing_model(model_id: &str) -> bool { let normalized = model_id.trim().to_ascii_lowercase(); normalized.is_empty() || matches!(normalized.as_str(), "unknown" | "null" | "none") @@ -2652,6 +2673,61 @@ mod tests { Ok(()) } + #[test] + fn test_scoped_backfill_matches_raw_alias_rows() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + // 代理日志按上游原文落库:带路由前缀和 :free 后缀的别名形式。 + // 精准回填的筛选必须归一化后匹配,否则这类行要等全量回填才更新。 + insert_usage_log( + &conn, + "openrouter-alias-zero-cost", + "claude", + "provider-1", + "openrouter/moonshot/kimi-k2-novel:free", + "proxy", + 1000, + 1_000_000, + 0, + 0, + 0, + 200, + "0", + )?; + } + + // 定价缺失时不应回填 + assert_eq!(db.backfill_missing_usage_costs()?, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million) + VALUES ('kimi-k2-novel', 'Kimi K2 Novel', '0.6', '2.5')", + [], + )?; + } + + // 按归一化 ID 精准回填,应命中以原始别名落库的行 + assert_eq!( + db.backfill_missing_usage_costs_for_model("kimi-k2-novel")?, + 1 + ); + + let conn = lock_conn!(db.conn); + let total_cost: String = conn.query_row( + "SELECT total_cost_usd + FROM proxy_request_logs WHERE request_id = 'openrouter-alias-zero-cost'", + [], + |row| row.get(0), + )?; + assert_eq!(total_cost, "0.600000"); + + Ok(()) + } + #[test] fn test_backfill_missing_usage_costs_keeps_claude_fresh_input() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 61c2aa91e..ea614838a 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -19,6 +19,8 @@ const W_TIER_NAMES: &[&str] = &[ crate::services::subscription::TIER_SEVEN_DAY_OPUS, crate::services::subscription::TIER_SEVEN_DAY_SONNET, ]; +// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)。 +const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY]; const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO]; const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH]; const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] = @@ -26,6 +28,7 @@ const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] = const TIER_LABEL_GROUPS: &[(&str, &[&str])] = &[ ("h", H_TIER_NAMES), ("w", W_TIER_NAMES), + ("m", M_TIER_NAMES), ("p", GEMINI_PRO_TIER_NAMES), ("f", GEMINI_FLASH_TIER_NAMES), ("l", GEMINI_FLASH_LITE_TIER_NAMES), @@ -878,7 +881,7 @@ mod tests { use crate::provider::{UsageData, UsageResult}; use crate::services::subscription::{ CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH, - TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS, + TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS, TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT, }; @@ -1094,6 +1097,36 @@ mod tests { assert!(s.contains("w50%"), "expected w50% in {s}"); } + #[test] + fn script_summary_token_plan_volcengine_three_tiers_with_monthly() { + // 火山方舟 Agent Plan 回 5h/周/月三档,托盘应包含 m(月)窗口, + // 不再静默丢弃。 + let r = usage_result( + true, + vec![ + usage_data(Some(TIER_FIVE_HOUR), 25.0), + usage_data(Some(TIER_WEEKLY_LIMIT), 30.0), + usage_data(Some(TIER_MONTHLY), 42.0), + ], + ); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("h25%"), "expected h25% in {s}"); + assert!(s.contains("w30%"), "expected w30% in {s}"); + assert!(s.contains("m42%"), "expected m42% in {s}"); + } + + #[test] + fn script_summary_token_plan_monthly_only_renders_label_not_raw_name() { + // 仅月窗口激活时不应回落到原始 "monthly" 机器名,而是走 m 标签。 + let r = usage_result(true, vec![usage_data(Some(TIER_MONTHLY), 60.0)]); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("m60%"), "expected m60% in {s}"); + assert!( + !s.contains("monthly"), + "raw tier name should not leak into label: {s}" + ); + } + #[test] fn script_summary_official_subscription_claude_uses_h_and_w_labels() { let r = usage_result( diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 242b6e835..3bcf29e6a 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -4,8 +4,9 @@ use std::fs; use serde_json::json; use cc_switch_lib::{ - get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError, - AppType, McpApps, McpServer, McpService, MultiAppConfig, + get_claude_mcp_path, get_claude_mcp_status, get_claude_settings_path, + import_default_config_test_hook, read_claude_mcp_config, update_settings, AppError, + AppSettings, AppType, McpApps, McpServer, McpService, MultiAppConfig, }; #[path = "support.rs"] @@ -673,6 +674,274 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() { ); } +#[test] +fn explicit_default_claude_dir_keeps_default_split_mcp_path() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let claude_dir = home.join(".claude"); + fs::create_dir_all(&claude_dir).expect("create explicit default claude dir"); + + update_settings(AppSettings { + claude_config_dir: Some(claude_dir.to_string_lossy().to_string()), + ..AppSettings::default() + }) + .expect("set explicit default claude config dir"); + + assert_eq!( + get_claude_mcp_path(), + home.join(".claude.json"), + "explicit default Claude dir should keep Claude Code's split MCP path" + ); + + let state = create_test_state().expect("create test state"); + McpService::upsert_server( + &state, + McpServer { + id: "claude-default".to_string(), + name: "Claude Default".to_string(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: true, + codex: false, + gemini: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ) + .expect("sync default Claude MCP"); + + assert!( + home.join(".claude.json").exists(), + "default split MCP file should be written at home/.claude.json" + ); + assert!( + !claude_dir.join(".claude.json").exists(), + "explicit default dir should not use nested .claude/.claude.json" + ); +} + +#[test] +fn custom_claude_dir_writes_mcp_inside_config_dir() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let custom_dir = home.join("profiles").join(".claude"); + fs::create_dir_all(&custom_dir).expect("create custom claude dir"); + + update_settings(AppSettings { + claude_config_dir: Some(custom_dir.to_string_lossy().to_string()), + ..AppSettings::default() + }) + .expect("set custom claude config dir"); + + let expected_mcp_path = custom_dir.join(".claude.json"); + assert_eq!( + get_claude_mcp_path(), + expected_mcp_path, + "custom Claude dir should keep MCP state inside the config dir" + ); + + let state = create_test_state().expect("create test state"); + McpService::upsert_server( + &state, + McpServer { + id: "claude-custom".to_string(), + name: "Claude Custom".to_string(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: true, + codex: false, + gemini: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ) + .expect("sync custom Claude MCP"); + + assert!( + expected_mcp_path.exists(), + "custom Claude MCP file should be written inside custom dir" + ); + assert!( + !home.join("profiles").join(".claude.json").exists(), + "custom Claude dir should not write sibling .claude.json" + ); +} + +#[test] +fn custom_claude_dir_sync_does_not_copy_default_profile() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let home_mcp_path = home.join(".claude.json"); + let default_profile = json!({ + "hasCompletedOnboarding": true, + "projects": { + "/home-project": { + "hasTrustDialogAccepted": true + } + }, + "mcpServers": { + "home-only": { + "type": "stdio", + "command": "home-command" + } + }, + "profileSentinel": "home-profile" + }); + let default_profile_text = + serde_json::to_string_pretty(&default_profile).expect("serialize default profile"); + fs::write(&home_mcp_path, &default_profile_text).expect("seed default Claude profile"); + + let custom_dir = home.join("profiles").join("work").join(".claude"); + fs::create_dir_all(&custom_dir).expect("create custom claude dir"); + update_settings(AppSettings { + claude_config_dir: Some(custom_dir.to_string_lossy().to_string()), + ..AppSettings::default() + }) + .expect("set custom claude config dir"); + + let expected_mcp_path = custom_dir.join(".claude.json"); + assert_eq!( + get_claude_mcp_path(), + expected_mcp_path, + "custom Claude dir should use nested .claude.json" + ); + assert!( + !expected_mcp_path.exists(), + "custom profile should start without a live MCP file" + ); + + let state = create_test_state().expect("create test state"); + McpService::upsert_server( + &state, + McpServer { + id: "custom-only".to_string(), + name: "Custom Only".to_string(), + server: json!({ + "type": "stdio", + "command": "custom-command" + }), + apps: McpApps { + claude: true, + codex: false, + gemini: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ) + .expect("sync custom Claude MCP"); + + let text = fs::read_to_string(&expected_mcp_path).expect("read custom Claude MCP"); + let value: serde_json::Value = serde_json::from_str(&text).expect("parse custom Claude MCP"); + let servers = value + .get("mcpServers") + .and_then(|v| v.as_object()) + .expect("custom profile should contain mcpServers"); + assert!( + servers.contains_key("custom-only"), + "custom profile should contain DB-managed Claude server" + ); + assert!( + !servers.contains_key("home-only"), + "custom profile should not inherit default profile MCP servers" + ); + assert!( + value.get("hasCompletedOnboarding").is_none(), + "custom profile should not inherit onboarding state" + ); + assert!( + value.get("projects").is_none(), + "custom profile should not inherit project trust state" + ); + assert!( + value.get("profileSentinel").is_none(), + "custom profile should not inherit unrelated default profile fields" + ); + assert_eq!( + fs::read_to_string(&home_mcp_path).expect("reread default Claude profile"), + default_profile_text, + "default Claude profile should remain unchanged" + ); +} + +#[test] +fn custom_claude_dir_read_only_mcp_queries_do_not_create_profile() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let home_mcp_path = home.join(".claude.json"); + fs::write( + &home_mcp_path, + serde_json::to_string_pretty(&json!({ + "mcpServers": { + "home-only": { + "type": "stdio", + "command": "home-command" + } + }, + "profileSentinel": "home-profile" + })) + .expect("serialize default profile"), + ) + .expect("seed default Claude profile"); + + let custom_dir = home.join("profiles").join("work").join(".claude"); + fs::create_dir_all(&custom_dir).expect("create custom claude dir"); + update_settings(AppSettings { + claude_config_dir: Some(custom_dir.to_string_lossy().to_string()), + ..AppSettings::default() + }) + .expect("set custom claude config dir"); + + let expected_mcp_path = custom_dir.join(".claude.json"); + assert!( + !expected_mcp_path.exists(), + "custom profile should start without a live MCP file" + ); + + let status = + futures::executor::block_on(get_claude_mcp_status()).expect("get Claude MCP status"); + assert_eq!( + status.user_config_path, + expected_mcp_path.to_string_lossy(), + "status should report the custom profile MCP path" + ); + assert!( + !status.user_config_exists, + "status should report missing custom profile MCP file" + ); + let text = + futures::executor::block_on(read_claude_mcp_config()).expect("read Claude MCP config"); + assert_eq!(text, None, "missing custom profile should read as None"); + assert!( + !expected_mcp_path.exists(), + "read-only MCP queries should not copy or create the custom profile" + ); +} + #[test] fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() { let _guard = test_mutex().lock().expect("acquire test mutex"); diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 5d4eb51a2..baea330a6 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -597,6 +597,14 @@ wire_api = "responses" let state = create_test_state_with_config(&initial_config).expect("create test state"); + let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config"); + proxy_config.listen_port = 0; + state + .db + .update_proxy_config(proxy_config) + .await + .expect("use ephemeral proxy port"); + ProviderService::switch(&state, AppType::Codex, "deepseek-provider") .expect("switch from official subscription to DeepSeek"); @@ -623,6 +631,12 @@ wire_api = "responses" .set_takeover_for_app("codex", true) .await .expect("enable Codex takeover"); + let proxy_status = state + .proxy_service + .get_status() + .await + .expect("read proxy status after takeover"); + let codex_proxy_base_url = format!("http://127.0.0.1:{}/v1", proxy_status.port); let auth_after_takeover: serde_json::Value = read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after takeover"); @@ -634,7 +648,7 @@ wire_api = "responses" let config_after_takeover = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config"); assert!( - config_after_takeover.contains("http://127.0.0.1:15721/v1"), + config_after_takeover.contains(&codex_proxy_base_url), "enabling takeover should point Codex config.toml at the local proxy" ); assert!( diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index 3fd9602b7..35ff1ee3a 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -33,6 +33,7 @@ pub fn reset_test_fs() { ".gemini", ".config", ".openclaw", + "profiles", ] { let path = home.join(sub); if path.exists() { diff --git a/src/App.tsx b/src/App.tsx index 966e48a6f..7a913e7ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,6 @@ import { Book, Brain, Wrench, - RefreshCw, History, BarChart2, Download, @@ -71,7 +70,11 @@ import { FailoverToggle } from "@/components/proxy/FailoverToggle"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; -import { SkillsPage } from "@/components/skills/SkillsPage"; +import { + SkillsPage, + getSkillsPageHeaderActions, + type SkillsPageSource, +} from "@/components/skills/SkillsPage"; import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog"; @@ -169,6 +172,8 @@ function App() { const sharedFeatureApp: AppId = activeApp === "claude-desktop" ? "claude" : activeApp; const [currentView, setCurrentView] = useState(getInitialView); + const [skillsDiscoverySource, setSkillsDiscoverySource] = + useState("repos"); const [settingsDefaultTab, setSettingsDefaultTab] = useState("general"); const [isAddOpen, setIsAddOpen] = useState(false); const [isWindowMaximized, setIsWindowMaximized] = useState(false); @@ -857,6 +862,11 @@ function App() { } }; + const handleOpenSkillsDiscovery = () => { + setSkillsDiscoverySource("repos"); + setCurrentView("skillsDiscovery"); + }; + const renderContent = () => { const content = (() => { switch (currentView) { @@ -884,7 +894,7 @@ function App() { return ( setCurrentView("skillsDiscovery")} + onOpenDiscovery={handleOpenSkillsDiscovery} currentApp={ sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp } @@ -897,6 +907,7 @@ function App() { initialApp={ sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp } + onSourceChange={setSkillsDiscoverySource} /> ); case "mcp": @@ -1312,7 +1323,7 @@ function App() { - + {getSkillsPageHeaderActions(skillsDiscoverySource).map( + ({ key, labelKey, Icon, execute }) => ( + + ), + )} )} {currentView === "providers" && ( diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 65471ca8a..7d46bc376 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -33,6 +33,8 @@ export const TIER_I18N_KEYS: Record = { gemini_flash_lite: "subscription.geminiFlashLite", // Token Plan(five_hour 已在上方官方映射中) weekly_limit: "subscription.sevenDay", + // 火山方舟 Agent Plan / Coding Plan 的月窗口 + monthly: "subscription.monthly", // GitHub Copilot premium: "subscription.copilotPremium", }; diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index e05e1c733..1d30a2091 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -8,6 +8,7 @@ import { usageApi, settingsApi, type AppId } from "@/lib/api"; import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot"; import { useSettingsQuery } from "@/lib/query"; import { resolveManagedAccountId } from "@/lib/authBinding"; +import { useDarkMode } from "@/hooks/useDarkMode"; import { extractCodexBaseUrl, extractCodexExperimentalBearerToken, @@ -196,6 +197,7 @@ const UsageScriptModal: React.FC = ({ const queryClient = useQueryClient(); const { data: settingsData } = useSettingsQuery(); const [showUsageConfirm, setShowUsageConfirm] = useState(false); + const isDarkMode = useDarkMode(); // 生成带国际化的预设模板 const PRESET_TEMPLATES = generatePresetTemplates(t); @@ -538,8 +540,10 @@ const UsageScriptModal: React.FC = ({ // Coding Plan 模板使用专用 API if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) { - // ZenMux 使用用户在脚本配置中手动填入的 API Key 和 Base URL + // ZenMux 手填 baseUrl/apiKey;火山是 native 供应商,baseUrl 走推理配置, + // 另用账号 AK/SK 签名查询控制面用量。 const isZenMux = script.codingPlanProvider === "zenmux"; + const isVolcengine = script.codingPlanProvider === "volcengine"; const baseUrl = isZenMux ? (script.baseUrl ?? "") : (providerCredentials.baseUrl ?? ""); @@ -547,7 +551,12 @@ const UsageScriptModal: React.FC = ({ ? (script.apiKey ?? "") : (providerCredentials.apiKey ?? ""); const { subscriptionApi } = await import("@/lib/api/subscription"); - const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey); + const quota = await subscriptionApi.getCodingPlanQuota( + baseUrl, + apiKey, + isVolcengine ? script.accessKeyId : undefined, + isVolcengine ? script.secretAccessKey : undefined, + ); if (quota.success && quota.tiers.length > 0) { const summary = quota.tiers .map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`) @@ -726,8 +735,9 @@ const UsageScriptModal: React.FC = ({ providerCredentials.baseUrl, ); const provider = script.codingPlanProvider || autoDetected || "kimi"; - // ZenMux 允许手动填写 API Key 和 Base URL,不清除 + // ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;其余清除。 const isZenMux = provider === "zenmux"; + const isVolcengine = provider === "volcengine"; setScript({ ...script, code: "", @@ -735,6 +745,8 @@ const UsageScriptModal: React.FC = ({ baseUrl: isZenMux ? script.baseUrl : undefined, accessToken: undefined, userId: undefined, + accessKeyId: isVolcengine ? script.accessKeyId : undefined, + secretAccessKey: isVolcengine ? script.secretAccessKey : undefined, codingPlanProvider: provider, }); } else if (presetName === TEMPLATE_TYPES.BALANCE) { @@ -1246,6 +1258,83 @@ const UsageScriptModal: React.FC = ({ )} + {/* 火山方舟:控制面用量查询需账号 AK/SK(与推理 Key 是两套凭据) */} + {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && + script.codingPlanProvider === "volcengine" && ( +
+
+

+ {t("usageScript.credentialsConfig")} +

+

+ {t("usageScript.volcengineAkSkHint")} +

+
+ +
+
+ + + setScript({ + ...script, + accessKeyId: e.target.value, + }) + } + placeholder="AKLT..." + autoComplete="off" + className="border-white/10" + /> +
+ +
+ +
+ + setScript({ + ...script, + secretAccessKey: e.target.value, + }) + } + placeholder="••••••••" + autoComplete="off" + className="border-white/10" + /> + {script.secretAccessKey && ( + + )} +
+
+
+
+ )} + {/* 通用配置(始终显示) */}
{/* 超时时间 */} @@ -1333,6 +1422,7 @@ const UsageScriptModal: React.FC = ({ height={480} language="javascript" showMinimap={false} + darkMode={isDarkMode} />
)} diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx index 8c83ecd66..88101d8a3 100644 --- a/src/components/common/FullScreenPanel.tsx +++ b/src/components/common/FullScreenPanel.tsx @@ -10,6 +10,7 @@ import { DRAG_REGION_STYLE, } from "@/lib/platform"; import { isTextEditableTarget } from "@/utils/domUtils"; +import { cn } from "@/lib/utils"; interface FullScreenPanelProps { isOpen: boolean; @@ -17,6 +18,11 @@ interface FullScreenPanelProps { onClose: () => void; children: React.ReactNode; footer?: React.ReactNode; + /** + * 覆盖内容区滚动容器的内边距/间距类。默认 `px-6 py-6 space-y-6`。 + * 通过 `cn`(twMerge) 合并,传入如 `pt-3` 只覆盖顶部内边距,其余保持默认。 + */ + contentClassName?: string; } const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx @@ -33,6 +39,7 @@ export const FullScreenPanel: React.FC = ({ onClose, children, footer, + contentClassName, }) => { React.useEffect(() => { if (isOpen) { @@ -136,7 +143,9 @@ export const FullScreenPanel: React.FC = ({ {/* Content */}
-
{children}
+
+ {children} +
{/* Footer */} diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index de6d03b85..d7460f2fb 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -297,6 +297,9 @@ export function AddProviderDialog({ const footer = !showUniversalTab || activeTab === "app-specific" ? ( <> + + {t("provider.addFooterHint")} + -
+
-
+

{provider.name} @@ -451,7 +451,7 @@ export function ProviderCard({ type="button" onClick={handleOpenWebsite} className={cn( - "inline-flex items-center text-sm max-w-[280px]", + "inline-flex max-w-full items-center overflow-hidden text-left text-sm", isClickableUrl ? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer" : "text-muted-foreground cursor-default", @@ -459,7 +459,7 @@ export function ProviderCard({ title={displayUrl} disabled={!isClickableUrl} > - {displayUrl} + {displayUrl} )}

diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index a991a5564..62edad709 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -45,6 +45,7 @@ import { import { useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { isTextEditableTarget } from "@/utils/domUtils"; interface ProviderListProps { providers: Record; @@ -245,8 +246,13 @@ export function ProviderList({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const key = event.key.toLowerCase(); if ((event.metaKey || event.ctrlKey) && key === "f") { + // 正在输入框/可编辑区域中时不抢占 Ctrl+F(例如添加供应商表单里 + // ProviderPresetSelector 的搜索框),避免与其同名快捷键冲突。 + if (isTextEditableTarget(document.activeElement)) return; event.preventDefault(); setIsSearchOpen(true); return; @@ -257,8 +263,8 @@ export function ProviderList({ } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + globalThis.addEventListener("keydown", handleKeyDown); + return () => globalThis.removeEventListener("keydown", handleKeyDown); }, []); useEffect(() => { diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx index 7702f9a9d..fedb0527f 100644 --- a/src/components/providers/forms/OmoFormFields.tsx +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -10,7 +10,6 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue, } from "@/components/ui/select"; import { Popover, @@ -471,6 +470,19 @@ export function OmoFormFields({ const firstIsUnavailable = Boolean(currentVariant) && !(modelVariantsMap[currentModel] || []).includes(currentVariant); + const defaultVariantLabel = t("omo.defaultWrapped", { + defaultValue: "(Default)", + }); + const getVariantLabel = (variant: string, index: number) => + firstIsUnavailable && index === 0 + ? t("omo.currentValueUnavailable", { + value: variant, + defaultValue: "{{value}} (current value, unavailable)", + }) + : variant; + const selectedVariantLabel = currentVariant + ? getVariantLabel(currentVariant, 0) + : defaultVariantLabel; return ( setSearchQuery(event.target.value)} onKeyDown={(event) => { @@ -278,7 +321,7 @@ export function ProviderPresetSelector({ aria-label={t("providerPreset.searchAriaLabel", { defaultValue: "Search provider presets", })} - className="w-48 h-8" + className="w-60 h-8" autoFocus /> )} @@ -359,6 +402,7 @@ export function ProviderPresetSelector({ {visiblePresetEntries.map((entry) => { const isSelected = selectedPresetId === entry.id; const isPartner = entry.preset.isPartner; + const isPrimePartner = entry.preset.primePartner; const presetCategory = entry.preset.category ?? "others"; return ( ); @@ -387,50 +439,47 @@ export function ProviderPresetSelector({
{onUniversalPresetSelect && universalProviderPresets.length > 0 && ( - <> -
- {universalProviderPresets.map((preset) => ( - + ))} + {onManageUniversalProviders && ( + - ))} - {onManageUniversalProviders && ( - - )} -
- + + + )} +
)}

{getCategoryHint()}

diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index 2ab97a6f3..a026a5800 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -13,6 +13,7 @@ import { MessageSquare, Clock, FolderOpen, + FileText, X, CheckSquare, } from "lucide-react"; @@ -897,6 +898,38 @@ export function SessionManagerPage({ appId }: { appId: string }) { )} + {selectedSession.sourcePath && ( + + + + + +

+ {selectedSession.sourcePath} +

+

+ {t("sessionManager.clickToCopyPath")} +

+
+
+ )}
diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 748c926ad..49cf0dc79 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { motion } from "framer-motion"; import { Loader2, @@ -102,6 +109,7 @@ export function SettingsPage({ const [activeTab, setActiveTab] = useState("general"); const [showRestartPrompt, setShowRestartPrompt] = useState(false); + const tabScrollContainerRef = useRef(null); useEffect(() => { if (open) { @@ -116,6 +124,12 @@ export function SettingsPage({ } }, [requiresRestart]); + useLayoutEffect(() => { + if (tabScrollContainerRef.current) { + tabScrollContainerRef.current.scrollTop = 0; + } + }, [activeTab]); + const closeAfterSave = useCallback(() => { // 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效 acknowledgeRestart(); @@ -226,7 +240,10 @@ export function SettingsPage({
-
+
{settings ? ( void; } export interface SkillsPageHandle { @@ -45,7 +54,35 @@ export interface SkillsPageHandle { openRepoManager: () => void; } -type SearchSource = "repos" | "skillssh"; +type SkillsPageHeaderAction = { + key: string; + sources: readonly SkillsPageSource[]; + labelKey: string; + Icon: LucideIcon; + execute: (page: SkillsPageHandle | null) => void; +}; + +const SKILLS_PAGE_HEADER_ACTIONS: readonly SkillsPageHeaderAction[] = [ + { + key: "refresh-repos", + sources: ["repos"], + labelKey: "skills.refresh", + Icon: RefreshCw, + execute: (page) => page?.refresh(), + }, + { + key: "manage-repos", + sources: ["repos", "skillssh"], + labelKey: "skills.repoManager", + Icon: Settings, + execute: (page) => page?.openRepoManager(), + }, +]; + +export const getSkillsPageHeaderActions = (source: SkillsPageSource) => + SKILLS_PAGE_HEADER_ACTIONS.filter((action) => + action.sources.includes(source), + ); const SKILLSSH_PAGE_SIZE = 20; @@ -54,7 +91,7 @@ const SKILLSSH_PAGE_SIZE = 20; * 用于浏览和安装来自仓库或 skills.sh 的 Skills */ export const SkillsPage = forwardRef( - ({ initialApp = "claude" }, ref) => { + ({ initialApp = "claude", onSourceChange }, ref) => { const { t } = useTranslation(); const [repoManagerOpen, setRepoManagerOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -64,7 +101,7 @@ export const SkillsPage = forwardRef( >("all"); // skills.sh 搜索状态 - const [searchSource, setSearchSource] = useState("repos"); + const [searchSource, setSearchSource] = useState("repos"); const [skillsShInput, setSkillsShInput] = useState(""); const [skillsShQuery, setSkillsShQuery] = useState(""); const [skillsShOffset, setSkillsShOffset] = useState(0); @@ -90,23 +127,25 @@ export const SkillsPage = forwardRef( data: skillsShResult, isLoading: loadingSkillsSh, isFetching: fetchingSkillsSh, + isPlaceholderData: placeholderSkillsSh, } = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset); // 当搜索结果返回时累积 useEffect(() => { - if (skillsShResult) { + if (skillsShResult && !placeholderSkillsSh) { if (skillsShOffset === 0) { setAccumulatedResults(skillsShResult.skills); } else { setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]); } } - }, [skillsShResult, skillsShOffset]); + }, [skillsShResult, skillsShOffset, placeholderSkillsSh]); // 手动提交搜索 const handleSkillsShSearch = () => { const trimmed = skillsShInput.trim(); if (trimmed.length < 2) return; + if (trimmed === skillsShQuery && skillsShOffset === 0) return; setSkillsShOffset(0); setAccumulatedResults([]); setSkillsShQuery(trimmed); @@ -314,13 +353,19 @@ export const SkillsPage = forwardRef( // 是否有更多 skills.sh 结果 const hasMoreSkillsSh = skillsShResult && accumulatedResults.length < skillsShResult.totalCount; + const searchingSkillsSh = + (loadingSkillsSh || fetchingSkillsSh) && accumulatedResults.length === 0; - // 无仓库时默认切换到 skills.sh + // 无仓库配置时默认切换到 skills.sh;仓库发现结果为空时仍保留仓库视图,方便手动刷新重试。 const effectiveSource = - searchSource === "repos" && skills.length === 0 && !loading + searchSource === "repos" && repos.length === 0 && !loading ? "skillssh" : searchSource; + useEffect(() => { + onSourceChange?.(effectiveSource); + }, [effectiveSource, onSourceChange]); + return (
{/* 技能网格(可滚动详情区域) */} @@ -528,7 +573,7 @@ export const SkillsPage = forwardRef( ) : ( /* ===== skills.sh 模式 ===== */ <> - {loadingSkillsSh && accumulatedResults.length === 0 ? ( + {searchingSkillsSh ? (
@@ -542,7 +587,7 @@ export const SkillsPage = forwardRef( {t("skills.skillssh.searchPlaceholder")}

- ) : accumulatedResults.length === 0 && !loadingSkillsSh ? ( + ) : accumulatedResults.length === 0 ? (

{t("skills.skillssh.noResults", { diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 82fb3ff32..da9786805 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; -import { ChevronDown, ChevronUp } from "lucide-react"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; import { cn } from "@/lib/utils"; const Select = SelectPrimitive.Root; @@ -87,6 +87,11 @@ const SelectItem = React.forwardRef< )} {...props} > + + + + + {children} )); diff --git a/src/components/universal/UniversalProviderFormModal.tsx b/src/components/universal/UniversalProviderFormModal.tsx index b7162fced..bb82c1a6d 100644 --- a/src/components/universal/UniversalProviderFormModal.tsx +++ b/src/components/universal/UniversalProviderFormModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useMemo } from "react"; +import { useDarkMode } from "@/hooks/useDarkMode"; import { useTranslation } from "react-i18next"; import { Eye, EyeOff, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -34,6 +35,7 @@ export function UniversalProviderFormModal({ editingProvider, initialPreset, }: UniversalProviderFormModalProps) { + const isDarkMode = useDarkMode(); const { t } = useTranslation(); const isEditMode = !!editingProvider; @@ -658,6 +660,7 @@ requires_openai_auth = true`; value={JSON.stringify(claudeConfigJson, null, 2)} onChange={() => {}} height={180} + darkMode={isDarkMode} />

)} @@ -673,6 +676,7 @@ requires_openai_auth = true`; value={JSON.stringify(codexConfigJson, null, 2)} onChange={() => {}} height={280} + darkMode={isDarkMode} />
)} @@ -688,6 +692,7 @@ requires_openai_auth = true`; value={JSON.stringify(geminiConfigJson, null, 2)} onChange={() => {}} height={140} + darkMode={isDarkMode} />
)} diff --git a/src/components/usage/ModelsDevPickerDialog.tsx b/src/components/usage/ModelsDevPickerDialog.tsx new file mode 100644 index 000000000..03025e160 --- /dev/null +++ b/src/components/usage/ModelsDevPickerDialog.tsx @@ -0,0 +1,452 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Check, Loader2, Search } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useUpdateModelPricing } from "@/lib/query/usage"; +import { isTextEditableTarget } from "@/utils/domUtils"; + +const MODELS_DEV_API_URL = "https://models.dev/api.json"; +// 全量约 5000 条:默认只展示最新发布的一批,搜索时才做全量匹配 +const DEFAULT_VISIBLE_ROWS = 50; +const MAX_VISIBLE_ROWS = 200; + +interface ModelsDevCost { + input?: number; + output?: number; + cache_read?: number; + cache_write?: number; +} + +interface ModelsDevModel { + id?: string; + name?: string; + release_date?: string; + cost?: ModelsDevCost; +} + +interface ModelsDevProvider { + id?: string; + name?: string; + models?: Record; +} + +type ModelsDevResponse = Record; + +interface ModelsDevEntry { + /** providerId/modelId,同一模型可能出现在多个供应商下 */ + key: string; + providerId: string; + providerName: string; + modelId: string; + /** 实际入库的 ID,与后端 clean_model_id_for_pricing 的归一化规则一致 */ + normalizedId: string; + modelName: string; + /** YYYY-MM-DD 或 YYYY-MM,缺失时为空串 */ + releaseDate: string; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +/** + * 与后端 clean_model_id_for_pricing(usage_stats.rs)保持一致: + * 取最后一个 '/' 之后的段、去掉 ':' 后缀、'@' 换成 '-'、转小写、去掉 [1m] 标记。 + * 成本归因查询用的就是这种归一化形式,原样入库的 ID 永远匹配不上。 + */ +export function normalizeModelIdForPricing(modelId: string): string { + const afterSlash = modelId.slice(modelId.lastIndexOf("/") + 1); + const beforeColon = afterSlash.split(":")[0] ?? ""; + let normalized = beforeColon.trim().replace(/@/g, "-").toLowerCase(); + if (normalized.endsWith("[1m]")) { + normalized = normalized.slice(0, -"[1m]".length).trim(); + } + return normalized; +} + +/** 转成后端可解析的非负十进制字符串(不能用 String(),小数可能变成科学计数法) */ +export function formatPrice(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "0"; + // toFixed 对 >=1e21 会退化成科学计数法;这种量级的"价格"只可能是脏数据,按 0 处理 + if (value >= 1e12) return "0"; + const trimmed = value.toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); + return trimmed || "0"; +} + +export function flattenModels(data: ModelsDevResponse): ModelsDevEntry[] { + const entries: ModelsDevEntry[] = []; + for (const [providerId, provider] of Object.entries(data)) { + if (!provider || typeof provider !== "object") continue; + const providerName = provider.name || providerId; + for (const [modelId, model] of Object.entries(provider.models ?? {})) { + const cost = model?.cost; + const input = typeof cost?.input === "number" ? cost.input : null; + const output = typeof cost?.output === "number" ? cost.output : null; + if (input === null && output === null) continue; + const normalizedId = normalizeModelIdForPricing(modelId); + if (!normalizedId) continue; + entries.push({ + key: `${providerId}/${modelId}`, + providerId, + providerName, + modelId, + normalizedId, + modelName: model?.name || modelId, + releaseDate: + typeof model?.release_date === "string" ? model.release_date : "", + input: input ?? 0, + output: output ?? 0, + cacheRead: typeof cost?.cache_read === "number" ? cost.cache_read : 0, + cacheWrite: + typeof cost?.cache_write === "number" ? cost.cache_write : 0, + }); + } + } + // 最新发布的排在前面 + entries.sort( + (a, b) => + b.releaseDate.localeCompare(a.releaseDate) || + a.modelName.localeCompare(b.modelName), + ); + return entries; +} + +interface ModelsDevPickerDialogProps { + open: boolean; + onClose: () => void; + /** 导入成功后调用(此时定价列表已刷新) */ + onImported: () => void; +} + +export function ModelsDevPickerDialog({ + open, + onClose, + onImported, +}: ModelsDevPickerDialogProps) { + const { t } = useTranslation(); + const updatePricing = useUpdateModelPricing(); + + const [search, setSearch] = useState(""); + const [providerFilter, setProviderFilter] = useState("all"); + const [selected, setSelected] = useState(null); + + // 每次打开时重置选择与过滤条件 + useEffect(() => { + if (open) { + setSearch(""); + setProviderFilter("all"); + setSelected(null); + } + }, [open]); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["models-dev-pricing"], + queryFn: async (): Promise => { + const res = await fetch(MODELS_DEV_API_URL); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return res.json(); + }, + enabled: open, + staleTime: 60 * 60 * 1000, + retry: 1, + }); + + const entries = useMemo(() => (data ? flattenModels(data) : []), [data]); + + const providers = useMemo(() => { + const map = new Map(); + for (const entry of entries) { + if (!map.has(entry.providerId)) { + map.set(entry.providerId, entry.providerName); + } + } + return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => + a.name.localeCompare(b.name), + ); + }, [entries]); + + const isFiltering = search.trim() !== "" || providerFilter !== "all"; + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + return entries.filter( + (entry) => + (providerFilter === "all" || entry.providerId === providerFilter) && + (!query || + entry.modelId.toLowerCase().includes(query) || + entry.normalizedId.includes(query) || + entry.modelName.toLowerCase().includes(query) || + entry.providerName.toLowerCase().includes(query)), + ); + }, [entries, search, providerFilter]); + + // 默认只展示最新发布的一批,搜索/筛选时展示全量匹配(设上限防卡顿) + const visible = useMemo( + () => + filtered.slice(0, isFiltering ? MAX_VISIBLE_ROWS : DEFAULT_VISIBLE_ROWS), + [filtered, isFiltering], + ); + + // 单选:点击未选中的行替换选择,点击已选中的行取消选择。 + // 限制单选是为了避免批量导入时每条都触发一次全量零成本回填扫描(见 update_model_pricing)。 + const toggleEntry = (entry: ModelsDevEntry) => { + setSelected((prev) => (prev?.key === entry.key ? null : entry)); + }; + + const handleImport = async () => { + if (!selected) return; + + try { + await updatePricing.mutateAsync({ + modelId: selected.normalizedId, + displayName: selected.modelName, + inputCost: formatPrice(selected.input), + outputCost: formatPrice(selected.output), + cacheReadCost: formatPrice(selected.cacheRead), + cacheCreationCost: formatPrice(selected.cacheWrite), + }); + + toast.success( + t("usage.modelsDevImported", { + name: selected.modelName, + defaultValue: "已导入 {{name}} 的定价", + }), + { closeButton: true }, + ); + onImported(); + } catch (error) { + toast.error(String(error)); + } + }; + + const priceColumns = (entry: ModelsDevEntry) => + [ + { label: t("usage.inputCost", "输入成本"), value: entry.input }, + { label: t("usage.outputCost", "输出成本"), value: entry.output }, + { label: t("usage.cacheReadCost", "缓存命中"), value: entry.cacheRead }, + { + label: t("usage.cacheWriteCost", "缓存创建"), + value: entry.cacheWrite, + }, + ] as const; + + return ( + { + if (!nextOpen && !updatePricing.isPending) { + onClose(); + } + }} + > + { + // 在搜索框里按 ESC 不应关闭弹窗丢掉已选模型(与 FullScreenPanel 的约定一致) + if (isTextEditableTarget(e.target)) { + e.preventDefault(); + } + }} + > + + + {t("usage.modelsDevPickerTitle", "从 models.dev 导入定价")} + + + {t( + "usage.modelsDevPickerDesc", + "选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个", + )} + + + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( + + + + {t("usage.modelsDevLoadError", "加载 models.dev 数据失败")}:{" "} + {error instanceof Error ? error.message : String(error)} + + + + + ) : ( + <> +
+ +
+ + setSearch(e.target.value)} + placeholder={t( + "usage.modelsDevSearchPlaceholder", + "搜索模型或供应商(全量搜索)...", + )} + className="pl-8" + /> +
+
+ +
+ {filtered.length === 0 ? ( +
+ {t("usage.modelsDevNoResults", "没有匹配的模型")} +
+ ) : ( +
+ {visible.map((entry) => ( +
toggleEntry(entry)} + className={`flex cursor-pointer items-center gap-3 px-3 py-2 ${ + selected?.key === entry.key + ? "bg-accent/50" + : "hover:bg-muted/40" + }`} + > + +
+
+ + {entry.modelName} + + + {entry.providerName} + + {entry.releaseDate && ( + + {entry.releaseDate} + + )} +
+
+ {entry.normalizedId} +
+
+
+ {priceColumns(entry).map((column) => ( +
+
+ {column.label} +
+
+ ${formatPrice(column.value)} +
+
+ ))} +
+
+ ))} + {filtered.length > visible.length && ( +
+ {isFiltering + ? t("usage.modelsDevTruncated", { + shown: visible.length, + total: filtered.length, + defaultValue: + "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围", + }) + : t("usage.modelsDevDefaultHint", { + shown: visible.length, + total: filtered.length, + defaultValue: + "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索", + })} +
+ )} +
+ )} +
+ + )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/usage/PricingEditModal.tsx b/src/components/usage/PricingEditModal.tsx index 8354cb479..18f81da28 100644 --- a/src/components/usage/PricingEditModal.tsx +++ b/src/components/usage/PricingEditModal.tsx @@ -1,13 +1,14 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Save, Plus } from "lucide-react"; +import { Save, Plus, Globe } from "lucide-react"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useUpdateModelPricing } from "@/lib/query/usage"; import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage"; +import { ModelsDevPickerDialog } from "./ModelsDevPickerDialog"; interface PricingEditModalProps { open: boolean; @@ -26,6 +27,7 @@ export function PricingEditModal({ }: PricingEditModalProps) { const { t } = useTranslation(); const updatePricing = useUpdateModelPricing(); + const [isPickerOpen, setIsPickerOpen] = useState(false); const [formData, setFormData] = useState({ modelId: model.modelId, @@ -111,6 +113,27 @@ export function PricingEditModal({ } > + {isNew && ( +
+

+ {t( + "usage.modelsDevHint", + "无需手动填写,可从 models.dev 选择模型定价", + )} +

+ +
+ )} +
{isNew && (
@@ -220,6 +243,17 @@ export function PricingEditModal({ />
+ + {isNew && isPickerOpen && ( + setIsPickerOpen(false)} + onImported={() => { + setIsPickerOpen(false); + onClose(); + }} + /> + )} ); } diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 9d49fcc27..4eb2485d4 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -108,9 +108,18 @@ export function UsageDashboard() { return getUsageRangePresetLabel(range.preset, t); } - return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date( - resolvedRange.endDate * 1000, - ).toLocaleString(locale)}`; + const startStr = new Date(resolvedRange.startDate * 1000).toLocaleString( + locale, + ); + + if (range.liveEndTime) { + return `${startStr} → ${t("usage.liveEndTimeNow", "现在")}`; + } + + const endStr = new Date(resolvedRange.endDate * 1000).toLocaleString( + locale, + ); + return `${startStr} - ${endStr}`; }, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]); // 顶栏下拉的选项池:Provider 列表只跟应用/时间范围走(不受自身选中值影响), diff --git a/src/components/usage/UsageDateRangePicker.tsx b/src/components/usage/UsageDateRangePicker.tsx index 067e7eeb1..845795a65 100644 --- a/src/components/usage/UsageDateRangePicker.tsx +++ b/src/components/usage/UsageDateRangePicker.tsx @@ -7,6 +7,7 @@ import { ChevronRight, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Popover, @@ -117,6 +118,9 @@ export function UsageDateRangePicker({ ); const [draftStart, setDraftStart] = useState(resolvedRange.startDate); const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate); + const [draftLiveEnd, setDraftLiveEnd] = useState( + selection.preset === "custom" ? (selection.liveEndTime ?? false) : false, + ); const [displayMonth, setDisplayMonth] = useState( () => new Date( @@ -136,6 +140,9 @@ export function UsageDateRangePicker({ const r = resolveUsageRange(selection); setDraftStart(r.startDate); setDraftEnd(r.endDate); + setDraftLiveEnd( + selection.preset === "custom" ? (selection.liveEndTime ?? false) : false, + ); setDisplayMonth( new Date( fromTs(r.startDate).getFullYear(), @@ -147,6 +154,15 @@ export function UsageDateRangePicker({ setError(null); }, [open, selection]); + // Keep draftEnd ticking when live mode is active and popover is open + useEffect(() => { + if (!open || !draftLiveEnd) return; + const tick = () => setDraftEnd(Math.floor(Date.now() / 1000)); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [open, draftLiveEnd]); + const calendarDays = useMemo( () => getCalendarDays(displayMonth), [displayMonth], @@ -169,6 +185,14 @@ export function UsageDateRangePicker({ /* Pick a date from the calendar */ const handleDatePick = (day: Date) => { setError(null); + + // When live end time is active, calendar only controls start date + if (draftLiveEnd) { + const nextTs = setDateKeepTime(draftStart, day); + setDraftStart(nextTs); + return; + } + const nextTs = setDateKeepTime( activeField === "start" ? draftStart : draftEnd, day, @@ -211,6 +235,7 @@ export function UsageDateRangePicker({ preset: "custom", customStartDate: draftStart, customEndDate: draftEnd, + liveEndTime: draftLiveEnd, }); setOpen(false); }; @@ -222,6 +247,7 @@ export function UsageDateRangePicker({ /* ── Field card (start / end) ── */ const renderField = (field: DraftField) => { const isActive = activeField === field; + const isEndLive = field === "end" && draftLiveEnd; const ts = field === "start" ? draftStart : draftEnd; const setTs = field === "start" ? setDraftStart : setDraftEnd; const label = @@ -232,12 +258,16 @@ export function UsageDateRangePicker({ return (
setActiveField(field)} + onClick={() => { + if (!isEndLive) setActiveField(field); + }} >
{label} @@ -245,27 +275,41 @@ export function UsageDateRangePicker({
{ + if (isEndLive) return; const next = parseDateInput(ts, e.target.value); setTs(next); const d = fromTs(next); setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1)); setError(null); }} - onFocus={() => setActiveField(field)} + onFocus={() => { + if (!isEndLive) setActiveField(field); + }} + readOnly={isEndLive} /> { + if (isEndLive) return; setTs(parseTimeInput(ts, e.target.value)); setError(null); }} - onFocus={() => setActiveField(field)} + onFocus={() => { + if (!isEndLive) setActiveField(field); + }} + readOnly={isEndLive} />
@@ -318,6 +362,23 @@ export function UsageDateRangePicker({ {renderField("start")} {renderField("end")} + + {error &&

{error}

}
diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index f13be211a..4e9c7c2d5 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -47,6 +47,7 @@ export interface ClaudeDesktopProviderPreset { apiKeyUrl?: string; category?: ProviderCategory; isPartner?: boolean; + primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形 partnerPromotionKey?: string; baseUrl: string; @@ -414,6 +415,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ }, { name: "Kimi", + primePartner: true, websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", category: "cn_official", baseUrl: "https://api.moonshot.cn/anthropic", @@ -429,6 +431,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ }, { name: "Kimi For Coding", + primePartner: true, websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", category: "cn_official", baseUrl: "https://api.kimi.com/coding/", @@ -897,17 +900,17 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ iconColor: "#000000", }, { - name: "CTok.ai", - websiteUrl: "https://ctok.ai", - apiKeyUrl: "https://ctok.ai", + name: "ETok.ai", + websiteUrl: "https://etok.ai", + apiKeyUrl: "https://etok.ai", category: "third_party", - baseUrl: "https://api.ctok.ai", + baseUrl: "https://api.etok.ai", mode: "direct", apiFormat: "anthropic", modelRoutes: passthroughRoutes(), isPartner: true, - partnerPromotionKey: "ctok", - icon: "ctok", + partnerPromotionKey: "etok", + icon: "etok", iconColor: "#000000", }, { diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 09ee68da1..1c8efa164 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -31,6 +31,7 @@ export interface ProviderPreset { settingsConfig: object; isOfficial?: boolean; // 标识是否为官方预设 isPartner?: boolean; // 标识是否为商业合作伙伴 + primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形 partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key category?: ProviderCategory; // 新增:分类 // 新增:指定该预设所使用的 API Key 字段名(默认 ANTHROPIC_AUTH_TOKEN) @@ -367,6 +368,7 @@ export const providerPresets: ProviderPreset[] = [ }, { name: "Kimi", + primePartner: true, websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", settingsConfig: { env: { @@ -384,11 +386,21 @@ export const providerPresets: ProviderPreset[] = [ }, { name: "Kimi For Coding", + primePartner: true, websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", settingsConfig: { env: { ANTHROPIC_BASE_URL: "https://api.kimi.com/coding/", ANTHROPIC_AUTH_TOKEN: "", + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "${CLAUDE_CODE_AUTO_COMPACT_WINDOW}", + }, + }, + templateValues: { + CLAUDE_CODE_AUTO_COMPACT_WINDOW: { + label: "Auto Compact Window", + placeholder: "262144", + defaultValue: "262144", + editorValue: "262144", }, }, category: "cn_official", @@ -971,19 +983,19 @@ export const providerPresets: ProviderPreset[] = [ iconColor: "#000000", }, { - name: "CTok.ai", - websiteUrl: "https://ctok.ai", - apiKeyUrl: "https://ctok.ai", + name: "ETok.ai", + websiteUrl: "https://etok.ai", + apiKeyUrl: "https://etok.ai", settingsConfig: { env: { - ANTHROPIC_BASE_URL: "https://api.ctok.ai", + ANTHROPIC_BASE_URL: "https://api.etok.ai", ANTHROPIC_AUTH_TOKEN: "", }, }, category: "third_party", isPartner: true, // 合作伙伴 - partnerPromotionKey: "ctok", // 促销信息 i18n key - icon: "ctok", + partnerPromotionKey: "etok", // 促销信息 i18n key + icon: "etok", iconColor: "#000000", }, { diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index d6c3c5fee..7380cebdd 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -19,6 +19,7 @@ export interface CodexProviderPreset { config: string; // 将写入 ~/.codex/config.toml(TOML 字符串) isOfficial?: boolean; // 标识是否为官方预设 isPartner?: boolean; // 标识是否为商业合作伙伴 + primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形 partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key category?: ProviderCategory; // 新增:分类 isCustomTemplate?: boolean; // 标识是否为自定义模板 @@ -421,6 +422,7 @@ requires_openai_auth = true`, }, { name: "Kimi", + primePartner: true, websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch", auth: generateThirdPartyAuth(""), @@ -451,6 +453,7 @@ requires_openai_auth = true`, }, { name: "Kimi For Coding", + primePartner: true, websiteUrl: "https://www.kimi.com/code/docs/", apiKeyUrl: "https://www.kimi.com/code/", auth: generateThirdPartyAuth(""), @@ -1220,20 +1223,20 @@ requires_openai_auth = true`, iconColor: "#000000", }, { - name: "CTok.ai", - websiteUrl: "https://ctok.ai", - apiKeyUrl: "https://ctok.ai", + name: "ETok.ai", + websiteUrl: "https://etok.ai", + apiKeyUrl: "https://etok.ai", auth: generateThirdPartyAuth(""), config: generateThirdPartyConfig( - "ctok", - "https://api.ctok.ai/v1", + "etok", + "https://api.etok.ai/v1", "gpt-5.5", ), - endpointCandidates: ["https://api.ctok.ai/v1"], + endpointCandidates: ["https://api.etok.ai/v1"], category: "third_party", isPartner: true, // 合作伙伴 - partnerPromotionKey: "ctok", // 促销信息 i18n key - icon: "ctok", + partnerPromotionKey: "etok", // 促销信息 i18n key + icon: "etok", iconColor: "#000000", }, { diff --git a/src/config/codingPlanProviders.ts b/src/config/codingPlanProviders.ts index fee5b908e..849d2bf3c 100644 --- a/src/config/codingPlanProviders.ts +++ b/src/config/codingPlanProviders.ts @@ -11,7 +11,7 @@ import { TEMPLATE_TYPES } from "@/config/constants"; export interface CodingPlanProviderEntry { /** 与后端 QuotaTier 的 `codingPlanProvider` 取值对齐 */ - id: "kimi" | "zhipu" | "minimax" | "zenmux"; + id: "kimi" | "zhipu" | "minimax" | "zenmux" | "volcengine"; /** UsageScriptModal 下拉显示用 */ label: string; /** base_url 匹配规则 */ @@ -35,6 +35,14 @@ export const CODING_PLAN_PROVIDERS: readonly CodingPlanProviderEntry[] = [ label: "ZenMux", pattern: /zenmux\./i, }, + { + // 火山方舟 Agent Plan / Coding Plan。base_url 形如 + // ark.cn-beijing.volces.com/api/coding[/v3];与后端 detect_provider 的 + // `volces.com/api/coding` 子串判断同效。 + id: "volcengine", + label: "火山方舟 (Volcengine)", + pattern: /volces\.com\/api\/coding/i, + }, ] as const; /** 根据 Base URL 自动检测 Coding Plan 供应商;未命中返回 null */ diff --git a/src/config/geminiProviderPresets.ts b/src/config/geminiProviderPresets.ts index 20bbec6dd..f16a30f43 100644 --- a/src/config/geminiProviderPresets.ts +++ b/src/config/geminiProviderPresets.ts @@ -23,6 +23,7 @@ export interface GeminiProviderPreset { description?: string; category?: ProviderCategory; isPartner?: boolean; + primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形 partnerPromotionKey?: string; endpointCandidates?: string[]; theme?: GeminiPresetTheme; @@ -280,23 +281,23 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [ iconColor: "#000000", }, { - name: "CTok.ai", - websiteUrl: "https://ctok.ai", - apiKeyUrl: "https://ctok.ai", + name: "ETok.ai", + websiteUrl: "https://etok.ai", + apiKeyUrl: "https://etok.ai", settingsConfig: { env: { - GOOGLE_GEMINI_BASE_URL: "https://api.ctok.ai/v1beta", + GOOGLE_GEMINI_BASE_URL: "https://api.etok.ai/v1beta", GEMINI_MODEL: "gemini-3.5-flash", }, }, - baseURL: "https://api.ctok.ai/v1beta", + baseURL: "https://api.etok.ai/v1beta", model: "gemini-3.5-flash", - description: "CTok", + description: "ETok", category: "third_party", isPartner: true, - partnerPromotionKey: "ctok", - endpointCandidates: ["https://api.ctok.ai/v1beta"], - icon: "ctok", + partnerPromotionKey: "etok", + endpointCandidates: ["https://api.etok.ai/v1beta"], + icon: "etok", iconColor: "#000000", }, { diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 21c728672..08094ce2e 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -104,6 +104,7 @@ export interface HermesProviderPreset { settingsConfig: HermesProviderSettingsConfig; isOfficial?: boolean; isPartner?: boolean; + primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形 partnerPromotionKey?: string; category?: ProviderCategory; templateValues?: Record; @@ -515,6 +516,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, { name: "Kimi", + primePartner: true, websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", settingsConfig: { name: "kimi", @@ -532,6 +534,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, { name: "Kimi For Coding", + primePartner: true, websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", settingsConfig: { name: "kimi_coding", @@ -1192,12 +1195,12 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, }, { - name: "CTok.ai", - websiteUrl: "https://ctok.ai", - apiKeyUrl: "https://ctok.ai", + name: "ETok.ai", + websiteUrl: "https://etok.ai", + apiKeyUrl: "https://etok.ai", settingsConfig: { - name: "ctok", - base_url: "https://api.ctok.ai", + name: "etok", + base_url: "https://api.etok.ai", api_key: "", api_mode: "anthropic_messages", models: [ @@ -1208,11 +1211,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, category: "third_party", isPartner: true, - partnerPromotionKey: "ctok", - icon: "ctok", + partnerPromotionKey: "etok", + icon: "etok", iconColor: "#000000", suggestedDefaults: { - model: { default: "claude-opus-4-8", provider: "ctok" }, + model: { default: "claude-opus-4-8", provider: "etok" }, }, }, { diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 00ddbce85..12695a9c4 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -26,6 +26,7 @@ export interface OpenClawProviderPreset { settingsConfig: OpenClawProviderConfig; isOfficial?: boolean; isPartner?: boolean; + primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形 partnerPromotionKey?: string; category?: ProviderCategory; /** Template variable definitions */ @@ -490,7 +491,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, { - name: "Kimi K2.7 Code", + name: "Kimi", + primePartner: true, websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { @@ -529,6 +531,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, { name: "Kimi For Coding", + primePartner: true, websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { @@ -2074,11 +2077,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, { - name: "CTok.ai", - websiteUrl: "https://ctok.ai", - apiKeyUrl: "https://ctok.ai", + name: "ETok.ai", + websiteUrl: "https://etok.ai", + apiKeyUrl: "https://etok.ai", settingsConfig: { - baseUrl: "https://api.ctok.ai", + baseUrl: "https://api.etok.ai", apiKey: "", api: "anthropic-messages", models: [ @@ -2092,8 +2095,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, category: "third_party", isPartner: true, - partnerPromotionKey: "ctok", - icon: "ctok", + partnerPromotionKey: "etok", + icon: "etok", iconColor: "#000000", templateValues: { apiKey: { @@ -2104,10 +2107,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "ctok/claude-opus-4-8", + primary: "etok/claude-opus-4-8", }, modelCatalog: { - "ctok/claude-opus-4-8": { alias: "Opus" }, + "etok/claude-opus-4-8": { alias: "Opus" }, }, }, }, diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index f69431c46..1b14f0a5a 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -9,6 +9,7 @@ export interface OpenCodeProviderPreset { settingsConfig: OpenCodeProviderConfig; isOfficial?: boolean; isPartner?: boolean; + primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形 partnerPromotionKey?: string; category?: ProviderCategory; templateValues?: Record; @@ -588,12 +589,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, { - name: "Kimi K2.7 Code", + name: "Kimi", + primePartner: true, websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch", settingsConfig: { npm: "@ai-sdk/openai-compatible", - name: "Kimi K2.7 Code", + name: "Kimi", options: { baseURL: "https://api.moonshot.cn/v1", apiKey: "", @@ -622,6 +624,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, { name: "Kimi For Coding", + primePartner: true, websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch", settingsConfig: { @@ -1651,14 +1654,14 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, { - name: "CTok.ai", - websiteUrl: "https://ctok.ai", - apiKeyUrl: "https://ctok.ai", + name: "ETok.ai", + websiteUrl: "https://etok.ai", + apiKeyUrl: "https://etok.ai", settingsConfig: { npm: "@ai-sdk/anthropic", - name: "CTok", + name: "ETok", options: { - baseURL: "https://api.ctok.ai/v1", + baseURL: "https://api.etok.ai/v1", apiKey: "", setCacheKey: true, }, @@ -1669,8 +1672,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, category: "third_party", isPartner: true, - partnerPromotionKey: "ctok", - icon: "ctok", + partnerPromotionKey: "etok", + icon: "etok", iconColor: "#000000", templateValues: { apiKey: { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 319ff8dd6..fcfe31f68 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -115,6 +115,7 @@ "editProviderHint": "Configuration will be applied to the current provider immediately after update.", "deleteProvider": "Delete Provider", "addNewProvider": "Add New Provider", + "addFooterHint": "💡 After choosing a preset, fill in the fields below (e.g. API Key)", "addClaudeProvider": "Add Claude Code Provider", "addCodexProvider": "Add Codex Provider", "addGeminiProvider": "Add Gemini Provider", @@ -753,7 +754,7 @@ "appConfigDirDescription": "Customize the storage location for CC Switch configuration (point to cloud sync folder to enable config sync)", "browsePlaceholderApp": "e.g., C:\\Users\\Administrator\\.cc-switch", "claudeConfigDir": "Claude Code Configuration Directory", - "claudeConfigDirDescription": "Override Claude configuration directory (settings.json) and keep claude.json (MCP) alongside it.", + "claudeConfigDirDescription": "Override Claude configuration directory (settings.json). Custom directories store .claude.json (MCP) inside the directory; the default ~/.claude directory keeps Claude Code's split ~/.claude.json path.", "codexConfigDir": "Codex Configuration Directory", "codexConfigDirDescription": "Override Codex configuration directory.", "geminiConfigDir": "Gemini Configuration Directory", @@ -1015,7 +1016,7 @@ "siliconflow": "SiliconFlow is an official partner of CC Switch", "ucloud": "Compshare offers an exclusive bonus for CC Switch users — register via this link to get ¥5 platform trial credit!", "micu": "Micu is an official partner of CC Switch", - "ctok": "Join the CTok community on the official website and subscribe to a plan.", + "etok": "Join the ETok community on the official website and subscribe to a plan.", "shengsuanyun": "Shengsuanyun offers exclusive benefits for CC Switch users. New users who register via this link get ¥10 free credits and 10% bonus on first top-up!", "doubaoseed": "DouBao offers exclusive benefits for CC Switch users: register via this link to get 500K tokens of inference credits for all DouBao text models and 5M tokens of free Seedance 2.0 credits.", "volcengine_agentplan": "Volcengine Ark Coding Plan offers exclusive benefits for CC Switch users: subscribe via this link and new customers get 75% off for the first two months, plus an extra 5% off with the exclusive invite code 6J6FV5N2 — as low as ¥9.4/month!", @@ -1473,6 +1474,8 @@ "customRangeHint": "Supports both date and time", "startTime": "Start Time", "endTime": "End Time", + "liveEndTime": "End time follows current time", + "liveEndTimeNow": "Now", "input": "Input", "output": "Output", "cacheWrite": "Creation", @@ -1510,6 +1513,20 @@ "editPricing": "Edit Pricing", "pricingAdded": "Pricing added", "pricingUpdated": "Pricing updated", + "importFromModelsDev": "Import from models.dev", + "modelsDevHint": "Skip manual entry — pick model pricing from models.dev", + "modelsDevPickerTitle": "Import Pricing from models.dev", + "modelsDevPickerDesc": "Select a model to import (prices in USD per million tokens). One model per import.", + "modelsDevSearchPlaceholder": "Search models or providers (full search)...", + "modelsDevAllProviders": "All providers", + "modelsDevLoadError": "Failed to load models.dev data", + "modelsDevRetry": "Retry", + "modelsDevImportButton": "Import", + "modelsDevImporting": "Importing...", + "modelsDevImported": "Imported pricing for {{name}}", + "modelsDevNoResults": "No matching models", + "modelsDevTruncated": "Showing first {{shown}} of {{total}} results — refine your search", + "modelsDevDefaultHint": "Showing the {{shown}} most recently released models (of {{total}}) — type to search all", "cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)", "cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)" }, @@ -1543,6 +1560,9 @@ "accessTokenPlaceholder": "Generate in 'Security Settings'", "userId": "User ID", "userIdPlaceholder": "e.g., 114514", + "accessKeyId": "AccessKey ID", + "secretAccessKey": "SecretAccessKey", + "volcengineAkSkHint": "Volcengine usage query needs an account-level AccessKey ID / Secret (different from the inference API key). Create them in the Volcengine console via the top-right account menu → 'API Access Keys'.", "defaultPlan": "Default Plan", "queryFailedMessage": "Query failed", "queryScript": "Query script (JavaScript)", @@ -2842,6 +2862,7 @@ "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", "weeklyLimit": "Weekly", + "monthly": "Monthly", "copilotPremium": "Premium", "utilization": "{{value}}%", "resetsIn": "Resets in {{time}}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index efbb2042b..e131d1d8f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -115,6 +115,7 @@ "editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。", "deleteProvider": "プロバイダーを削除", "addNewProvider": "新しいプロバイダーを追加", + "addFooterHint": "💡 プリセットを選んだら、下のフィールド(API Key など)を入力してください", "addClaudeProvider": "Claude Code プロバイダーを追加", "addCodexProvider": "Codex プロバイダーを追加", "addGeminiProvider": "Gemini プロバイダーを追加", @@ -753,7 +754,7 @@ "appConfigDirDescription": "CC Switch の保存場所をカスタマイズします(クラウド同期フォルダを指定すると設定を同期できます)", "browsePlaceholderApp": "例: C:\\\\Users\\\\Administrator\\\\.cc-switch", "claudeConfigDir": "Claude Code 設定ディレクトリ", - "claudeConfigDirDescription": "Claude の設定ディレクトリ(settings.json)を上書きし、claude.json(MCP)も同じ場所に置きます。", + "claudeConfigDirDescription": "Claude の設定ディレクトリ(settings.json)を上書きします。カスタムディレクトリでは .claude.json(MCP)をその中に保存し、既定の ~/.claude では Claude Code の既定どおり ~/.claude.json を使います。", "codexConfigDir": "Codex 設定ディレクトリ", "codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。", "geminiConfigDir": "Gemini 設定ディレクトリ", @@ -1015,7 +1016,7 @@ "siliconflow": "SiliconFlow は CC Switch の公式パートナーです", "ucloud": "Compshare は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、5元のプラットフォーム体験クレジットがもらえます!", "micu": "Micu は CC Switch の公式パートナーです", - "ctok": "公式サイトで CTok コミュニティに参加し、プランを購読してください。", + "etok": "公式サイトで ETok コミュニティに参加し、プランを購読してください。", "shengsuanyun": "胜算云はCC Switchユーザーに特別特典を提供しています。このリンクから登録すると、10元分の無料クレジットと初回チャージ10%ボーナスがもらえます!", "doubaoseed": "豆包大モデルは CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると、豆包全テキストモデル50万トークンの推論クレジットおよびSeedance2.0の500万トークン無料クレジットがもらえます。", "volcengine_agentplan": "火山方舟 Coding Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟 Coding Plan を購読すると、新規のお客様は最初の 2 か月が 75% オフ、さらに専用招待コード 6J6FV5N2 で 5% オフが加算され、月額 9.4 元から!", @@ -1473,6 +1474,8 @@ "customRangeHint": "日付と時刻の両方に対応", "startTime": "開始時刻", "endTime": "終了時刻", + "liveEndTime": "終了時刻を現在時刻に追従", + "liveEndTimeNow": "現在", "input": "Input", "output": "Output", "cacheWrite": "作成", @@ -1510,6 +1513,20 @@ "editPricing": "価格設定を編集", "pricingAdded": "価格設定が追加されました", "pricingUpdated": "価格設定が更新されました", + "importFromModelsDev": "models.dev からインポート", + "modelsDevHint": "手動入力の代わりに、models.dev からモデル価格を選択できます", + "modelsDevPickerTitle": "models.dev から価格をインポート", + "modelsDevPickerDesc": "インポートするモデルを選択してください(価格単位:USD / 100万トークン)。1回につき1モデルです。", + "modelsDevSearchPlaceholder": "モデルまたはプロバイダーを検索(全件検索)...", + "modelsDevAllProviders": "すべてのプロバイダー", + "modelsDevLoadError": "models.dev データの読み込みに失敗しました", + "modelsDevRetry": "再試行", + "modelsDevImportButton": "インポート", + "modelsDevImporting": "インポート中...", + "modelsDevImported": "{{name}} の価格をインポートしました", + "modelsDevNoResults": "一致するモデルがありません", + "modelsDevTruncated": "{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください", + "modelsDevDefaultHint": "最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます", "cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)", "cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)" }, @@ -1543,6 +1560,9 @@ "accessTokenPlaceholder": "「Security Settings」で生成", "userId": "ユーザー ID", "userIdPlaceholder": "例: 114514", + "accessKeyId": "AccessKey ID", + "secretAccessKey": "SecretAccessKey", + "volcengineAkSkHint": "Volcengine の使用量照会にはアカウントレベルの AccessKey ID / Secret が必要です(推論 API キーとは別物)。Volcengine コンソール右上のアカウントメニュー →「API アクセスキー」で作成してください。", "defaultPlan": "デフォルトプラン", "queryFailedMessage": "照会に失敗しました", "queryScript": "照会スクリプト (JavaScript)", @@ -2842,6 +2862,7 @@ "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", "weeklyLimit": "週間", + "monthly": "月間", "copilotPremium": "プレミアム", "utilization": "{{value}}%", "resetsIn": "{{time}}後にリセット", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index d70abf8e1..5cd42b343 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -115,6 +115,7 @@ "editProviderHint": "更新設定後將立即套用至目前供應商。", "deleteProvider": "刪除供應商", "addNewProvider": "新增供應商", + "addFooterHint": "💡 選擇預設後,請在下方填寫 API Key 等欄位", "addClaudeProvider": "新增 Claude Code 供應商", "addCodexProvider": "新增 Codex 供應商", "addGeminiProvider": "新增 Gemini 供應商", @@ -753,7 +754,7 @@ "appConfigDirDescription": "自訂 CC Switch 的設定儲存位置(指定至雲端同步資料夾即可雲端同步設定)", "browsePlaceholderApp": "例如:C:\\Users\\Administrator\\.cc-switch", "claudeConfigDir": "Claude Code 設定目錄", - "claudeConfigDirDescription": "覆寫 Claude 設定目錄 (settings.json),同時會在同級存放 Claude MCP 的 claude.json。", + "claudeConfigDirDescription": "覆寫 Claude 設定目錄 (settings.json)。真正自訂目錄會在目錄內存放 Claude MCP 的 .claude.json;預設 ~/.claude 目錄仍使用 Claude 預設的 ~/.claude.json。", "codexConfigDir": "Codex 設定目錄", "codexConfigDirDescription": "覆寫 Codex 設定目錄。", "geminiConfigDir": "Gemini 設定目錄", @@ -987,7 +988,7 @@ "siliconflow": "矽基流動是 CC Switch 的官方合作夥伴", "ucloud": "優雲智算為 CC Switch 的使用者提供了特殊優惠,透過此連結註冊,可以獲得 5 元平台體驗金!", "micu": "Micu 是 CC Switch 的官方合作夥伴", - "ctok": "官網加入 CTok 社群,訂閱方案。", + "etok": "官網加入 ETok 社群,訂閱方案。", "shengsuanyun": "勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!", "doubaoseed": "豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度", "volcengine_agentplan": "火山方舟 Coding Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 Coding Plan,新客戶首兩個月享 2.5 折優惠,再用專屬邀請碼 6J6FV5N2 領取獎勵疊加 9.5 折,低至 9.4 元/月!", @@ -1445,6 +1446,8 @@ "customRangeHint": "支援日期與時間", "startTime": "開始時間", "endTime": "結束時間", + "liveEndTime": "結束時間跟隨當前時刻", + "liveEndTimeNow": "現在", "input": "Input", "output": "Output", "cacheWrite": "建立", @@ -1482,6 +1485,20 @@ "editPricing": "編輯定價", "pricingAdded": "定價已新增", "pricingUpdated": "定價已更新", + "importFromModelsDev": "從 models.dev 匯入", + "modelsDevHint": "無需手動填寫,可從 models.dev 選擇模型定價", + "modelsDevPickerTitle": "從 models.dev 匯入定價", + "modelsDevPickerDesc": "選擇要匯入的模型(價格單位:USD / 百萬 tokens),每次匯入一個", + "modelsDevSearchPlaceholder": "搜尋模型或供應商(全量搜尋)...", + "modelsDevAllProviders": "全部供應商", + "modelsDevLoadError": "載入 models.dev 資料失敗", + "modelsDevRetry": "重試", + "modelsDevImportButton": "匯入", + "modelsDevImporting": "匯入中...", + "modelsDevImported": "已匯入 {{name}} 的定價", + "modelsDevNoResults": "沒有符合的模型", + "modelsDevTruncated": "僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍", + "modelsDevDefaultHint": "預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋", "cacheReadCostPerMillion": "快取讀取成本 (每百萬 tokens, USD)", "cacheCreationCostPerMillion": "快取寫入成本 (每百萬 tokens, USD)" }, @@ -1515,6 +1532,9 @@ "accessTokenPlaceholder": "在「安全設定」裡產生", "userId": "使用者 ID", "userIdPlaceholder": "例如:114514", + "accessKeyId": "AccessKey ID", + "secretAccessKey": "SecretAccessKey", + "volcengineAkSkHint": "火山用量查詢需帳號級 AccessKey ID / Secret(與推理 API Key 不同)。請在火山引擎主控台右上角帳號選單 →「API存取金鑰」中建立。", "defaultPlan": "預設方案", "queryFailedMessage": "查詢失敗", "queryScript": "查詢腳本(JavaScript)", @@ -2814,6 +2834,7 @@ "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", "weeklyLimit": "每週", + "monthly": "每月", "copilotPremium": "進階請求", "utilization": "{{value}}%", "resetsIn": "{{time}} 後重設", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 41c3e355a..bf2205734 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -115,6 +115,7 @@ "editProviderHint": "更新配置后将立即应用到当前供应商。", "deleteProvider": "删除供应商", "addNewProvider": "添加新供应商", + "addFooterHint": "💡 选择预设后,请在下方填写 API Key 等字段", "addClaudeProvider": "添加 Claude Code 供应商", "addCodexProvider": "添加 Codex 供应商", "addGeminiProvider": "添加 Gemini 供应商", @@ -753,7 +754,7 @@ "appConfigDirDescription": "自定义 CC Switch 的配置存储位置(指定到云同步文件夹即可云同步配置)", "browsePlaceholderApp": "例如:C:\\Users\\Administrator\\.cc-switch", "claudeConfigDir": "Claude Code 配置目录", - "claudeConfigDirDescription": "覆盖 Claude 配置目录 (settings.json),同时会在同级存放 Claude MCP 的 claude.json。", + "claudeConfigDirDescription": "覆盖 Claude 配置目录 (settings.json)。真正自定义目录会在目录内存放 Claude MCP 的 .claude.json;默认 ~/.claude 目录仍使用 Claude 默认的 ~/.claude.json。", "codexConfigDir": "Codex 配置目录", "codexConfigDirDescription": "覆盖 Codex 配置目录。", "geminiConfigDir": "Gemini 配置目录", @@ -1015,7 +1016,7 @@ "siliconflow": "硅基流动是 CC Switch 的官方合作伙伴", "ucloud": "优云智算为CC Switch 的用户提供了特殊优惠,通过此链接注册,可以获得五元平台体验金!", "micu": "Micu 是 CC Switch 的官方合作伙伴", - "ctok": "官网加入CTok社群,订阅套餐。", + "etok": "官网加入ETok社群,订阅套餐。", "shengsuanyun": "胜算云为 CC Switch 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!", "doubaoseed": "豆包大模型为 CC Switch 的用户提供了专属福利:通过此链接注册即可获取豆包全系列文本模型 50万tokens推理额度以及Seedance2.0的500万tokens免费额度", "volcengine_agentplan": "火山方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!", @@ -1473,6 +1474,8 @@ "customRangeHint": "支持日期与时间", "startTime": "开始时间", "endTime": "结束时间", + "liveEndTime": "结束时间跟随当前时刻", + "liveEndTimeNow": "现在", "input": "Input", "output": "Output", "cacheWrite": "创建", @@ -1510,6 +1513,20 @@ "editPricing": "编辑定价", "pricingAdded": "定价已添加", "pricingUpdated": "定价已更新", + "importFromModelsDev": "从 models.dev 导入", + "modelsDevHint": "无需手动填写,可从 models.dev 选择模型定价", + "modelsDevPickerTitle": "从 models.dev 导入定价", + "modelsDevPickerDesc": "选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个", + "modelsDevSearchPlaceholder": "搜索模型或供应商(全量搜索)...", + "modelsDevAllProviders": "全部供应商", + "modelsDevLoadError": "加载 models.dev 数据失败", + "modelsDevRetry": "重试", + "modelsDevImportButton": "导入", + "modelsDevImporting": "导入中...", + "modelsDevImported": "已导入 {{name}} 的定价", + "modelsDevNoResults": "没有匹配的模型", + "modelsDevTruncated": "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围", + "modelsDevDefaultHint": "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索", "cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)", "cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)" }, @@ -1543,6 +1560,9 @@ "accessTokenPlaceholder": "在'安全设置'里生成", "userId": "用户 ID", "userIdPlaceholder": "例如:114514", + "accessKeyId": "AccessKey ID", + "secretAccessKey": "SecretAccessKey", + "volcengineAkSkHint": "火山用量查询需账号级 AccessKey ID / Secret(与推理 API Key 不同)。请在火山引擎控制台右上角账号菜单 →「API访问密钥」中创建。", "defaultPlan": "默认套餐", "queryFailedMessage": "查询失败", "queryScript": "查询脚本(JavaScript)", @@ -2842,6 +2862,7 @@ "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", "weeklyLimit": "每周", + "monthly": "每月", "copilotPremium": "高级请求", "utilization": "{{value}}%", "resetsIn": "{{time}}后重置", diff --git a/src/icons/extracted/ctok.svg b/src/icons/extracted/ctok.svg deleted file mode 100644 index ee178c83d..000000000 --- a/src/icons/extracted/ctok.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/icons/extracted/etok.png b/src/icons/extracted/etok.png new file mode 100644 index 000000000..faf131f88 Binary files /dev/null and b/src/icons/extracted/etok.png differ diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index 43fbe7c8d..14c3e6ae9 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -10,6 +10,7 @@ import _ccsub from "./ccsub.svg?url"; import _claudecn from "./claudecn.png"; import _cherryin from "./cherryin.png"; import _eflowcode from "./eflowcode.png"; +import _etok from "./etok.png"; import _hermes from "./hermes.png"; import _huoshan from "./huoshan.png"; import _pateway from "./pateway.jpg"; @@ -35,7 +36,6 @@ export const icons: Record = { cloudflare: `Cloudflare`, cohere: `Cohere`, copilot: `Copilot`, - ctok: `CTok`, crazyrouter: `CrazyRouter`, cubence: `Cubence`, deepseek: `DeepSeek`, @@ -50,7 +50,7 @@ export const icons: Record = { huawei: `Huawei`, huggingface: `HuggingFace`, hunyuan: `Hunyuan`, - kimi: `Kimi`, + kimi: `Kimi`, meta: `Meta`, midjourney: `Midjourney`, minimax: `Minimax`, @@ -103,6 +103,7 @@ export const iconUrls: Record = { claudecn: _claudecn, cherryin: _cherryin, eflowcode: _eflowcode, + etok: _etok, hermes: _hermes, huoshan: _huoshan, pateway: _pateway, diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 1aa4e77fe..0f68025f2 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -202,12 +202,12 @@ export const iconMetadata: Record = { keywords: [], defaultColor: "currentColor", }, - ctok: { - name: "ctok", - displayName: "CTok", + etok: { + name: "etok", + displayName: "ETok", category: "ai-provider", - keywords: ["ctok", "ai", "programming"], - defaultColor: "#3B82F6", + keywords: ["etok", "ai", "programming"], + defaultColor: "#F97316", }, cubence: { name: "cubence", @@ -305,7 +305,7 @@ export const iconMetadata: Record = { displayName: "Kimi", category: "ai-provider", keywords: ["moonshot"], - defaultColor: "#6366F1", + defaultColor: "#1783FF", }, meta: { name: "meta", diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index d11000ffa..0d2436308 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -9,8 +9,16 @@ export const subscriptionApi = { getCodingPlanQuota: ( baseUrl: string, apiKey: string, + // 火山方舟用账号 AK/SK 签名查询用量;其他供应商不传。 + accessKeyId?: string, + secretAccessKey?: string, ): Promise => - invoke("get_coding_plan_quota", { baseUrl, apiKey }), + invoke("get_coding_plan_quota", { + baseUrl, + apiKey, + accessKeyId, + secretAccessKey, + }), getBalance: ( baseUrl: string, apiKey: string, diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index f0aa30744..ea95dc0cc 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -26,6 +26,7 @@ type RequestLogsKey = { preset: UsageRangeSelection["preset"]; customStartDate?: number; customEndDate?: number; + liveEndTime?: boolean; appType?: string; providerName?: string; model?: string; @@ -40,6 +41,7 @@ export const usageKeys = { customStartDate: number | undefined, customEndDate: number | undefined, filters?: UsageScopeFilters, + liveEndTime?: boolean, ) => [ ...usageKeys.all, @@ -47,6 +49,7 @@ export const usageKeys = { preset, customStartDate ?? 0, customEndDate ?? 0, + liveEndTime ?? false, filters?.appType ?? null, filters?.providerName ?? null, filters?.model ?? null, @@ -55,7 +58,8 @@ export const usageKeys = { preset: UsageRangeSelection["preset"], customStartDate: number | undefined, customEndDate: number | undefined, - filters?: UsageScopeFilters, + filters?: Pick, + liveEndTime?: boolean, ) => [ ...usageKeys.all, @@ -63,6 +67,7 @@ export const usageKeys = { preset, customStartDate ?? 0, customEndDate ?? 0, + liveEndTime ?? false, filters?.providerName ?? null, filters?.model ?? null, ] as const, @@ -71,6 +76,7 @@ export const usageKeys = { customStartDate: number | undefined, customEndDate: number | undefined, filters?: UsageScopeFilters, + liveEndTime?: boolean, ) => [ ...usageKeys.all, @@ -78,6 +84,7 @@ export const usageKeys = { preset, customStartDate ?? 0, customEndDate ?? 0, + liveEndTime ?? false, filters?.appType ?? null, filters?.providerName ?? null, filters?.model ?? null, @@ -87,6 +94,7 @@ export const usageKeys = { customStartDate: number | undefined, customEndDate: number | undefined, filters?: UsageScopeFilters, + liveEndTime?: boolean, ) => [ ...usageKeys.all, @@ -94,6 +102,7 @@ export const usageKeys = { preset, customStartDate ?? 0, customEndDate ?? 0, + liveEndTime ?? false, filters?.appType ?? null, filters?.providerName ?? null, filters?.model ?? null, @@ -103,6 +112,7 @@ export const usageKeys = { customStartDate: number | undefined, customEndDate: number | undefined, filters?: UsageScopeFilters, + liveEndTime?: boolean, ) => [ ...usageKeys.all, @@ -110,6 +120,7 @@ export const usageKeys = { preset, customStartDate ?? 0, customEndDate ?? 0, + liveEndTime ?? false, filters?.appType ?? null, filters?.providerName ?? null, filters?.model ?? null, @@ -121,6 +132,7 @@ export const usageKeys = { key.preset, key.customStartDate ?? 0, key.customEndDate ?? 0, + key.liveEndTime ?? false, key.appType ?? "", key.providerName ?? "", key.model ?? "", @@ -159,6 +171,7 @@ export function useUsageSummary( range.customStartDate, range.customEndDate, effective, + range.liveEndTime, ), queryFn: () => { const { startDate, endDate } = resolveUsageRange(range); @@ -186,6 +199,7 @@ export function useUsageSummaryByApp( range.customStartDate, range.customEndDate, filters, + range.liveEndTime, ), queryFn: () => { const { startDate, endDate } = resolveUsageRange(range); @@ -213,6 +227,7 @@ export function useUsageTrends( range.customStartDate, range.customEndDate, effective, + range.liveEndTime, ), queryFn: () => { const { startDate, endDate } = resolveUsageRange(range); @@ -241,6 +256,7 @@ export function useProviderStats( range.customStartDate, range.customEndDate, effective, + range.liveEndTime, ), queryFn: () => { const { startDate, endDate } = resolveUsageRange(range); @@ -269,6 +285,7 @@ export function useModelStats( range.customStartDate, range.customEndDate, effective, + range.liveEndTime, ), queryFn: () => { const { startDate, endDate } = resolveUsageRange(range); @@ -296,6 +313,7 @@ export function useRequestLogs({ preset: range.preset, customStartDate: range.customStartDate, customEndDate: range.customEndDate, + liveEndTime: range.liveEndTime, appType: filters.appType, providerName: filters.providerName, model: filters.model, diff --git a/src/lib/usageRange.ts b/src/lib/usageRange.ts index 4b70ee1d2..6cedcf320 100644 --- a/src/lib/usageRange.ts +++ b/src/lib/usageRange.ts @@ -49,7 +49,9 @@ export function resolveUsageRange( }; case "custom": { const startDate = selection.customStartDate ?? endDate - DAY_SECONDS; - const customEndDate = selection.customEndDate ?? endDate; + const customEndDate = selection.liveEndTime + ? endDate + : (selection.customEndDate ?? endDate); return { startDate, endDate: customEndDate, diff --git a/src/types.ts b/src/types.ts index 0c3c33b0e..6e5f80274 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,6 +62,8 @@ export interface UsageScript { baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用) accessToken?: string; // 访问令牌(NewAPI 模板使用) userId?: string; // 用户ID(NewAPI 模板使用) + accessKeyId?: string; // 火山方舟 AccessKey ID(用量查询签名用,与推理 Key 分离) + secretAccessKey?: string; // 火山方舟 SecretAccessKey codingPlanProvider?: string; // Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax") autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用) autoIntervalMinutes?: number; // 自动查询间隔(分钟)- 别名字段 diff --git a/src/types/usage.ts b/src/types/usage.ts index 6e6f957dd..256d58aec 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -152,6 +152,9 @@ export interface UsageRangeSelection { preset: UsageRangePreset; customStartDate?: number; customEndDate?: number; + /** When true (custom mode only), endDate resolves to "now" instead of the + * fixed customEndDate snapshot, and the end-time field becomes read-only. */ + liveEndTime?: boolean; } /** diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 6d953642c..6d5576a3d 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -400,7 +400,7 @@ export const hasTomlCommonConfigSnippet = ( const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/; const TOML_BASE_URL_PATTERN = - /^\s*base_url\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; + /^\s*base_url\s*=\s*(?:"((?:\\.|[^"\\\r\n])*)"|'([^'\r\n]*)')\s*(?:#.*)?$/; const TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN = /^\s*experimental_bearer_token\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; const TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN = @@ -553,11 +553,12 @@ const findTomlAssignmentInRange = ( ): TomlAssignmentMatch | undefined => { for (let index = startIndex; index < endIndex; index += 1) { const match = lines[index].match(pattern); - if (match?.[2]) { + const value = match?.[2] ?? match?.[1]; + if (value) { return { index, sectionName, - value: match[2], + value, }; } } @@ -565,6 +566,26 @@ const findTomlAssignmentInRange = ( return undefined; }; +const findTomlAssignmentsInRange = ( + lines: string[], + pattern: RegExp, + startIndex: number, + endIndex: number, + sectionName?: string, +): TomlAssignmentMatch[] => { + const matches: TomlAssignmentMatch[] = []; + + for (let index = startIndex; index < endIndex; index += 1) { + const match = lines[index].match(pattern); + const value = match?.[2] ?? match?.[1]; + if (value) { + matches.push({ index, sectionName, value }); + } + } + + return matches; +}; + const findTomlLineInRange = ( lines: string[], pattern: RegExp, @@ -595,14 +616,15 @@ const findTomlAssignments = ( } const match = line.match(pattern); - if (!match?.[2]) { + const value = match?.[2] ?? match?.[1]; + if (!value) { return; } assignments.push({ index, sectionName: currentSectionName, - value: match[2], + value, }); }); @@ -1228,18 +1250,20 @@ export const setCodexBaseUrl = ( if (targetSectionName) { const sectionRange = getTomlSectionRange(lines, targetSectionName); - const targetMatch = sectionRange - ? findTomlAssignmentInRange( + const targetMatches = sectionRange + ? findTomlAssignmentsInRange( lines, TOML_BASE_URL_PATTERN, sectionRange.bodyStartIndex, sectionRange.bodyEndIndex, targetSectionName, ) - : undefined; + : []; - if (targetMatch) { - lines.splice(targetMatch.index, 1); + if (targetMatches.length > 0) { + for (const match of [...targetMatches].reverse()) { + lines.splice(match.index, 1); + } return finalizeTomlText(lines); } } @@ -1257,18 +1281,22 @@ export const setCodexBaseUrl = ( if (targetSectionName) { let targetSectionRange = getTomlSectionRange(lines, targetSectionName); - const targetMatch = targetSectionRange - ? findTomlAssignmentInRange( + const targetMatches = targetSectionRange + ? findTomlAssignmentsInRange( lines, TOML_BASE_URL_PATTERN, targetSectionRange.bodyStartIndex, targetSectionRange.bodyEndIndex, targetSectionName, ) - : undefined; + : []; - if (targetMatch) { - lines[targetMatch.index] = replacementLine; + if (targetMatches.length > 0) { + const [firstMatch, ...duplicateMatches] = targetMatches; + lines[firstMatch.index] = replacementLine; + for (const match of [...duplicateMatches].reverse()) { + lines.splice(match.index, 1); + } return finalizeTomlText(lines); } @@ -1291,14 +1319,18 @@ export const setCodexBaseUrl = ( } const topLevelEndIndex = getTopLevelEndIndex(lines); - const topLevelMatch = findTomlAssignmentInRange( + const topLevelMatches = findTomlAssignmentsInRange( lines, TOML_BASE_URL_PATTERN, 0, topLevelEndIndex, ); - if (topLevelMatch) { - lines[topLevelMatch.index] = replacementLine; + if (topLevelMatches.length > 0) { + const [firstMatch, ...duplicateMatches] = topLevelMatches; + lines[firstMatch.index] = replacementLine; + for (const match of [...duplicateMatches].reverse()) { + lines.splice(match.index, 1); + } return finalizeTomlText(lines); } diff --git a/tests/components/ModelsDevPickerDialog.test.ts b/tests/components/ModelsDevPickerDialog.test.ts new file mode 100644 index 000000000..a162759d8 --- /dev/null +++ b/tests/components/ModelsDevPickerDialog.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { + flattenModels, + formatPrice, + normalizeModelIdForPricing, +} from "@/components/usage/ModelsDevPickerDialog"; + +describe("normalizeModelIdForPricing", () => { + it("keeps already-normalized ids unchanged", () => { + expect(normalizeModelIdForPricing("claude-opus-4-5")).toBe( + "claude-opus-4-5", + ); + }); + + it("strips the vendor prefix before the last slash", () => { + expect(normalizeModelIdForPricing("z-ai/glm-4.7")).toBe("glm-4.7"); + expect(normalizeModelIdForPricing("clarifai/main/models/mm-poly-8b")).toBe( + "mm-poly-8b", + ); + }); + + it("lowercases the id", () => { + expect(normalizeModelIdForPricing("MiniMaxAI/MiniMax-M2.1")).toBe( + "minimax-m2.1", + ); + }); + + it("truncates colon suffixes", () => { + expect(normalizeModelIdForPricing("claude-sonnet-4-thinking:8192")).toBe( + "claude-sonnet-4-thinking", + ); + }); + + it("maps @ to -", () => { + expect(normalizeModelIdForPricing("claude-sonnet-4@20250514")).toBe( + "claude-sonnet-4-20250514", + ); + }); + + it("strips the [1m] context marker", () => { + expect(normalizeModelIdForPricing("claude-sonnet-4-5[1m]")).toBe( + "claude-sonnet-4-5", + ); + }); + + it("combines all rules", () => { + expect(normalizeModelIdForPricing("Vendor/Claude-Sonnet-4@2025:free")).toBe( + "claude-sonnet-4-2025", + ); + }); +}); + +describe("formatPrice", () => { + it("formats integers without a decimal point", () => { + expect(formatPrice(5)).toBe("5"); + expect(formatPrice(25)).toBe("25"); + }); + + it("trims trailing zeros", () => { + expect(formatPrice(0.5)).toBe("0.5"); + expect(formatPrice(6.25)).toBe("6.25"); + expect(formatPrice(1.0395)).toBe("1.0395"); + }); + + it("keeps up to six decimal places", () => { + expect(formatPrice(0.000001)).toBe("0.000001"); + expect(formatPrice(0.0000004)).toBe("0"); + }); + + it("returns 0 for zero, negative and non-finite values", () => { + expect(formatPrice(0)).toBe("0"); + expect(formatPrice(-1)).toBe("0"); + expect(formatPrice(NaN)).toBe("0"); + expect(formatPrice(Infinity)).toBe("0"); + }); + + it("never produces exponent notation", () => { + // 后端 Decimal::from_str 不接受科学计数法 + expect(formatPrice(1e-8)).toBe("0"); + expect(formatPrice(1e21)).toBe("0"); + for (const value of [5, 0.5, 0.000123, 123456.789]) { + expect(formatPrice(value)).toMatch(/^\d+(\.\d+)?$/); + } + }); +}); + +describe("flattenModels", () => { + it("flattens providers, fills defaults and sorts by release date desc", () => { + const entries = flattenModels({ + acme: { + id: "acme", + name: "Acme AI", + models: { + "old-model": { + id: "old-model", + name: "Old Model", + release_date: "2024-01-01", + cost: { input: 1, output: 2 }, + }, + "new-model": { + id: "new-model", + name: "New Model", + release_date: "2025-06-01", + cost: { input: 3, output: 6, cache_read: 0.3, cache_write: 3.75 }, + }, + "free-model": { + id: "free-model", + name: "No Cost Model", + }, + }, + }, + bare: { + models: { + "Vendor/Some-Model:free": { + release_date: "2025-01", + cost: { input: 0.1 }, + }, + }, + }, + }); + + expect(entries.map((e) => e.key)).toEqual([ + "acme/new-model", + "bare/Vendor/Some-Model:free", + "acme/old-model", + ]); + + const newModel = entries[0]; + expect(newModel.normalizedId).toBe("new-model"); + expect(newModel.cacheRead).toBe(0.3); + expect(newModel.cacheWrite).toBe(3.75); + + // 没有 name 的 provider 用 id 兜底;缺失的成本字段补 0 + const bareModel = entries[1]; + expect(bareModel.providerName).toBe("bare"); + expect(bareModel.normalizedId).toBe("some-model"); + expect(bareModel.output).toBe(0); + expect(bareModel.cacheRead).toBe(0); + + // 完全没有定价的模型被过滤 + expect(entries.some((e) => e.modelId === "free-model")).toBe(false); + }); +}); diff --git a/tests/components/ProviderCardLayout.test.ts b/tests/components/ProviderCardLayout.test.ts new file mode 100644 index 000000000..d07469e7f --- /dev/null +++ b/tests/components/ProviderCardLayout.test.ts @@ -0,0 +1,26 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const PROVIDER_CARD_TSX = path.resolve( + __dirname, + "..", + "..", + "src", + "components", + "providers", + "ProviderCard.tsx", +); + +describe("ProviderCard layout", () => { + const source = fs.readFileSync(PROVIDER_CARD_TSX, "utf8"); + + it("lets website links use available card width before truncating", () => { + expect(source).not.toContain("max-w-[280px]"); + expect(source).toContain("flex min-w-0 flex-1 items-center gap-2"); + expect(source).toContain("min-w-0 flex-1 space-y-1"); + expect(source).toContain( + "inline-flex max-w-full items-center overflow-hidden text-left text-sm", + ); + }); +}); diff --git a/tests/components/ProviderPresetSelector.test.tsx b/tests/components/ProviderPresetSelector.test.tsx index 26c44a3a9..2107e139c 100644 --- a/tests/components/ProviderPresetSelector.test.tsx +++ b/tests/components/ProviderPresetSelector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { TFunction } from "i18next"; @@ -60,6 +60,7 @@ type TestPresetEntry = { websiteUrl: string; settingsConfig: Record; category: ProviderCategory; + primePartner?: boolean; }; }; @@ -200,13 +201,14 @@ describe("ProviderPresetSelector pure helpers", () => { expect(getIds(filterPresetEntries(presetEntries, "聚合", t))).toEqual([]); }); - it("支持 A-Z 排序、original 副本恢复原顺序,并且 getVisible 先 filter 再 sort", () => { + it("支持 A-Z 排序、original 模式将官方分类置顶,并且 getVisible 先 filter 再 sort", () => { const originalMode: PresetSortMode = "original"; const nameAscMode: PresetSortMode = "nameAsc"; const original = sortPresetEntries(presetEntries, originalMode, t); expect(original).not.toBe(presetEntries); - expect(getIds(original)).toEqual(["gamma", "alpha", "beta", "delta"]); + // original 模式置顶官方分类(alpha),其余保持传入顺序。 + expect(getIds(original)).toEqual(["alpha", "gamma", "beta", "delta"]); expect(getIds(sortPresetEntries(presetEntries, nameAscMode, t))).toEqual([ "alpha", @@ -226,16 +228,81 @@ describe("ProviderPresetSelector pure helpers", () => { ), ).toEqual(["alpha", "beta", "delta", "gamma"]); }); + + it("original 模式按「官方 → 尊享伙伴 → 其余」三段排序,各组内部保序且双重身份不重复", () => { + // 故意打乱传入顺序,验证: + // - official 组置顶(officialOnly、officialPrime 按出现顺序); + // - 非官方且 primePartner 的预设居中(primeOnly); + // - 其余保持传入顺序(restFirst、restLast); + // - 既是 official 又是 primePartner 的预设只归入官方组、不在 prime 组重复。 + const mixed: TestPresetEntry[] = [ + { + id: "restFirst", + preset: { + name: "Rest First", + websiteUrl: "https://rest-first.example.com", + settingsConfig: {}, + category: "third_party", + }, + }, + { + id: "primeOnly", + preset: { + name: "Prime Only", + websiteUrl: "https://prime-only.example.com", + settingsConfig: {}, + category: "cn_official", + primePartner: true, + }, + }, + { + id: "officialOnly", + preset: { + name: "Official Only", + websiteUrl: "https://official-only.example.com", + settingsConfig: {}, + category: "official", + }, + }, + { + id: "officialPrime", + preset: { + name: "Official Prime", + websiteUrl: "https://official-prime.example.com", + settingsConfig: {}, + category: "official", + primePartner: true, + }, + }, + { + id: "restLast", + preset: { + name: "Rest Last", + websiteUrl: "https://rest-last.example.com", + settingsConfig: {}, + category: "aggregator", + }, + }, + ]; + + expect(getIds(sortPresetEntries(mixed, "original", t))).toEqual([ + "officialOnly", + "officialPrime", + "primeOnly", + "restFirst", + "restLast", + ]); + }); }); describe("ProviderPresetSelector", () => { - it("默认按传入的预设数组顺序渲染,不按分类或名称重新排序", () => { + it("默认(original 模式)将官方分类置顶,其余保持传入顺序", () => { renderSelector(); expect(getPresetButtonTexts()).toEqual([ "providerPreset.custom", - "preset.gamma", "preset.alpha", + "preset.gamma", "Beta Gateway", "Delta Mirror", ]); @@ -259,8 +326,8 @@ describe("ProviderPresetSelector", () => { expect(getPresetButtonTexts()).toEqual([ "providerPreset.custom", - "preset.gamma", "preset.alpha", + "preset.gamma", "Beta Gateway", "Delta Mirror", ]); @@ -421,18 +488,80 @@ describe("ProviderPresetSelector", () => { ).toBeInTheDocument(); }); - it("点击搜索区域外自动收起并清空", async () => { + it("按 Ctrl+F 快捷键打开搜索输入框", async () => { + const user = userEvent.setup(); + renderSelector(); + + // 初始没有搜索输入框 + expect( + screen.queryByRole("textbox", { + name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i, + }), + ).not.toBeInTheDocument(); + + // 按 Ctrl+F 展开输入框 + await user.keyboard("{Control>}f{/Control}"); + expect(getSearchInput()).toBeInTheDocument(); + }); + + it("搜索后点击预设按钮可选中预设且不清空搜索关键词", async () => { + const user = userEvent.setup(); + const onPresetChange = vi.fn(); + renderSelector({ onPresetChange }); + + await user.click(getSearchButton()); + await user.type(getSearchInput(), "gateway"); + + await user.click(screen.getByRole("button", { name: "Beta Gateway" })); + + expect(onPresetChange).toHaveBeenCalledWith("beta"); + // 搜索框仍展开、关键词保留 + expect(getSearchInput()).toBeInTheDocument(); + expect(getSearchInput()).toHaveValue("gateway"); + }); + + it("搜索已打开、焦点在别处时再次 Ctrl+F 把焦点移回搜索框且保留关键词", async () => { const user = userEvent.setup(); renderSelector(); + await user.click(getSearchButton()); + await user.type(getSearchInput(), "gateway"); + + // 选中 preset 后焦点离开搜索框(搜索框仍展开、关键词保留) + await user.click(screen.getByRole("button", { name: "Beta Gateway" })); + expect(getSearchInput()).not.toHaveFocus(); + + // 再次 Ctrl+F:setSearchOpen(true) 同值不重渲染、autoFocus 不重触发, + // 需靠快捷键命中时的命令式聚焦把焦点移回搜索框,且不清空关键词 + await user.keyboard("{Control>}f{/Control}"); + await waitFor(() => expect(getSearchInput()).toHaveFocus()); + expect(getSearchInput()).toHaveValue("gateway"); + }); + + it("点击组件外区域自动收起并清空", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const form = useForm(); + return ( +
+ +
Outside
+ + ); + }; + render(); + await user.click(getSearchButton()); await user.type(getSearchInput(), "gateway"); expect(getSearchInput()).toBeInTheDocument(); - // 点击搜索区域外的元素(custom 按钮)应收起搜索框 - await user.click( - screen.getByRole("button", { name: "providerPreset.custom" }), - ); + // 点击组件外的元素应收起搜索框 + await user.click(screen.getByTestId("outside")); expect( screen.queryByRole("textbox", { diff --git a/tests/components/SelectItemIndicator.test.ts b/tests/components/SelectItemIndicator.test.ts new file mode 100644 index 000000000..37c7e48b6 --- /dev/null +++ b/tests/components/SelectItemIndicator.test.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SELECT_TSX = path.resolve( + __dirname, + "..", + "..", + "src", + "components", + "ui", + "select.tsx", +); + +const stripComments = (source: string) => + source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); + +describe("SelectItem indicator structure", () => { + const source = stripComments(fs.readFileSync(SELECT_TSX, "utf8")); + + it("renders an item indicator before item text", () => { + const indicatorIndex = source.indexOf("SelectPrimitive.ItemIndicator"); + const itemTextIndex = source.indexOf("SelectPrimitive.ItemText"); + + expect(indicatorIndex).toBeGreaterThan(-1); + expect(itemTextIndex).toBeGreaterThan(-1); + expect(indicatorIndex).toBeLessThan(itemTextIndex); + }); + + it("wraps the indicator in the first direct span", () => { + const itemTextIndex = source.indexOf("SelectPrimitive.ItemText"); + const beforeItemText = source.slice(0, itemTextIndex); + const lastSpanOpen = beforeItemText.lastIndexOf(" { fireEvent.click(screen.getByText("settings.advanced.data.title")); // 有文件时,点击导入按钮执行 importConfig - fireEvent.click( - screen.getByRole("button", { name: /settings\.import/ }), - ); + fireEvent.click(screen.getByRole("button", { name: /settings\.import/ })); expect(importExportMock.importConfig).toHaveBeenCalled(); fireEvent.click( @@ -359,6 +357,20 @@ describe("SettingsPage Component", () => { expect(importExportMock.clearSelection).toHaveBeenCalled(); }); + it("should reset tab content scroll position when switching settings tabs", () => { + const { container } = renderSettingsPage(); + const scrollContainer = container.querySelector( + ".overflow-y-auto", + ) as HTMLDivElement | null; + + expect(scrollContainer).not.toBeNull(); + + scrollContainer!.scrollTop = 640; + fireEvent.click(screen.getByText("settings.tabAdvanced")); + + expect(scrollContainer!.scrollTop).toBe(0); + }); + it("should pass onImportSuccess callback to useImportExport hook", async () => { const onImportSuccess = vi.fn(); diff --git a/tests/components/SkillsPageInstall.test.tsx b/tests/components/SkillsPageInstall.test.tsx index 09b520129..2ea050173 100644 --- a/tests/components/SkillsPageInstall.test.tsx +++ b/tests/components/SkillsPageInstall.test.tsx @@ -5,33 +5,49 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { SkillsPage, + getSkillsPageHeaderActions, type SkillsPageHandle, } from "@/components/skills/SkillsPage"; import type { DiscoverableSkill, + SkillRepo, SkillsShDiscoverableSkill, SkillsShSearchResult, } from "@/lib/api/skills"; const installMutateAsyncMock = vi.fn(); +let discoverableSkillsMock: DiscoverableSkill[] = []; +let skillReposMock: SkillRepo[] = []; +const refetchDiscoverableMock = vi.fn(); // Stable cache so repeated renders see referentially-equal data. // SkillsPage has `useEffect([skillsShResult, ...])` that calls setState — a // fresh object every render would loop forever. const searchCache = new Map< string, - { data: SkillsShSearchResult | undefined; isLoading: boolean; isFetching: boolean } + { + data: SkillsShSearchResult | undefined; + isLoading: boolean; + isFetching: boolean; + isPlaceholderData?: boolean; + } >(); const setSearchResult = ( query: string, offset: number, result: SkillsShSearchResult | undefined, + state: Partial<{ + isLoading: boolean; + isFetching: boolean; + isPlaceholderData: boolean; + }> = {}, ) => { searchCache.set(`${query}:${offset}`, { data: result, isLoading: false, isFetching: false, + ...state, }); }; @@ -45,10 +61,10 @@ vi.mock("sonner", () => ({ vi.mock("@/hooks/useSkills", () => ({ useDiscoverableSkills: () => ({ - data: [] as DiscoverableSkill[], + data: discoverableSkillsMock, isLoading: false, isFetching: false, - refetch: vi.fn(), + refetch: refetchDiscoverableMock, }), useInstalledSkills: () => ({ data: [], @@ -58,7 +74,7 @@ vi.mock("@/hooks/useSkills", () => ({ mutateAsync: installMutateAsyncMock, }), useSkillRepos: () => ({ - data: [], + data: skillReposMock, refetch: vi.fn(), }), useAddSkillRepo: () => ({ @@ -88,10 +104,35 @@ const makeSkillsShSkill = ( ...overrides, }); +const makeDiscoverableSkill = ( + overrides: Partial = {}, +): DiscoverableSkill => ({ + key: "repo-skill:owner-a:repo-a", + name: "Repo Skill", + description: "Skill from a configured repository", + directory: "repo-skill", + readmeUrl: "https://example.com/repo-skill", + repoOwner: "owner-a", + repoName: "repo-a", + repoBranch: "main", + ...overrides, +}); + +const makeSkillRepo = (overrides: Partial = {}): SkillRepo => ({ + owner: "owner-a", + name: "repo-a", + branch: "main", + enabled: true, + ...overrides, +}); + describe("SkillsPage - skills.sh install (regression)", () => { beforeEach(() => { installMutateAsyncMock.mockReset(); installMutateAsyncMock.mockResolvedValue({}); + discoverableSkillsMock = []; + skillReposMock = []; + refetchDiscoverableMock.mockReset(); searchCache.clear(); }); @@ -156,4 +197,143 @@ describe("SkillsPage - skills.sh install (regression)", () => { expect(callArgs.skill.repoName).toBe("repo-b"); expect(callArgs.skill.name).toBe("Agent Browser B"); }); + + it("keeps skills.sh results when submitting the same query again", async () => { + const figmaSkill = makeSkillsShSkill({ + key: "figma-use:figma:mcp-server-guide", + name: "figma-use", + directory: "figma-use", + repoOwner: "figma", + repoName: "mcp-server-guide", + }); + + setSearchResult("figma", 0, { + skills: [figmaSkill], + totalCount: 1, + query: "figma", + }); + + render(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: /skills\.sh/i })); + const input = screen.getByPlaceholderText( + "skills.skillssh.searchPlaceholder", + ); + await user.type(input, "figma"); + + const searchButton = screen.getByRole("button", { + name: "skills.search", + }); + await user.click(searchButton); + + await waitFor(() => { + expect(screen.getByText("figma-use")).toBeInTheDocument(); + }); + + await user.click(searchButton); + + expect(screen.getByText("figma-use")).toBeInTheDocument(); + }); + + it("shows the skills.sh loading state while a new query is fetching", async () => { + const figmaSkill = makeSkillsShSkill({ + key: "figma-use:figma:mcp-server-guide", + name: "figma-use", + directory: "figma-use", + repoOwner: "figma", + repoName: "mcp-server-guide", + }); + + setSearchResult("figma", 0, { + skills: [figmaSkill], + totalCount: 1, + query: "figma", + }); + setSearchResult("react", 0, undefined, { isFetching: true }); + + render(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: /skills\.sh/i })); + const input = screen.getByPlaceholderText( + "skills.skillssh.searchPlaceholder", + ); + await user.type(input, "figma"); + + const searchButton = screen.getByRole("button", { + name: "skills.search", + }); + await user.click(searchButton); + + await waitFor(() => { + expect(screen.getByText("figma-use")).toBeInTheDocument(); + }); + + await user.clear(input); + await user.type(input, "react"); + await user.click(searchButton); + + expect(screen.getByText("skills.skillssh.loading")).toBeInTheDocument(); + }); + + it("reports the effective skills.sh source to parent chrome", async () => { + const onSourceChange = vi.fn(); + + render(); + + await waitFor(() => { + expect(onSourceChange).toHaveBeenCalledWith("skillssh"); + }); + }); + + it("keeps the repository source when configured repositories return no discoverable skills", async () => { + skillReposMock = [makeSkillRepo()]; + const onSourceChange = vi.fn(); + + render(); + + await waitFor(() => { + expect(onSourceChange).toHaveBeenCalledWith("repos"); + }); + expect( + screen.getByPlaceholderText("skills.searchPlaceholder"), + ).toBeVisible(); + }); + + it("can switch back to repository results after discoverable skills refresh", async () => { + const onSourceChange = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(onSourceChange).toHaveBeenCalledWith("skillssh"); + }); + + await user.click(screen.getByRole("button", { name: /skills\.sh/i })); + + discoverableSkillsMock = [makeDiscoverableSkill()]; + skillReposMock = [makeSkillRepo()]; + rerender( + , + ); + + await user.click( + screen.getByRole("button", { name: "skills.searchSource.repos" }), + ); + + expect(screen.getByText("Repo Skill")).toBeInTheDocument(); + expect(onSourceChange).toHaveBeenCalledWith("repos"); + }); + + it("exposes repository-only header actions for the parent chrome", () => { + expect( + getSkillsPageHeaderActions("repos").map((action) => action.key), + ).toEqual(["refresh-repos", "manage-repos"]); + expect( + getSkillsPageHeaderActions("skillssh").map((action) => action.key), + ).toEqual(["manage-repos"]); + }); }); diff --git a/tests/config/claudeProviderPresets.test.ts b/tests/config/claudeProviderPresets.test.ts index dc1f4397c..beb031ea2 100644 --- a/tests/config/claudeProviderPresets.test.ts +++ b/tests/config/claudeProviderPresets.test.ts @@ -1,6 +1,33 @@ import { describe, expect, it } from "vitest"; import { providerPresets } from "@/config/claudeProviderPresets"; +describe("Kimi For Coding Provider Preset", () => { + const kimiForCoding = providerPresets.find( + (p) => p.name === "Kimi For Coding", + ); + + it("should include Kimi For Coding preset", () => { + expect(kimiForCoding).toBeDefined(); + }); + + it("should use template placeholder for Claude Code auto-compact window", () => { + const env = (kimiForCoding!.settingsConfig as any).env; + expect(env).toHaveProperty( + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + "${CLAUDE_CODE_AUTO_COMPACT_WINDOW}", + ); + }); + + it("should expose auto-compact window as editable template value with Kimi default", () => { + const values = (kimiForCoding!.templateValues as any) + ?.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + expect(values).toBeDefined(); + expect(values.defaultValue).toBe("262144"); + expect(values.editorValue).toBe("262144"); + expect(values.label).toBe("Auto Compact Window"); + }); +}); + describe("AWS Bedrock Provider Presets", () => { const bedrockAksk = providerPresets.find( (p) => p.name === "AWS Bedrock (AKSK)", diff --git a/tests/utils/providerConfigUtils.codex.test.ts b/tests/utils/providerConfigUtils.codex.test.ts index 31b7af15a..108ccaaeb 100644 --- a/tests/utils/providerConfigUtils.codex.test.ts +++ b/tests/utils/providerConfigUtils.codex.test.ts @@ -63,6 +63,48 @@ describe("Codex TOML utils", () => { expect(extractCodexModelName(output2)).toBe("new-model"); }); + it("updates a double-quoted base_url containing single quotes without duplicating it", () => { + const input = [ + 'model_provider = "custom"', + 'model = "gpt-5.4"', + "", + "[model_providers.custom]", + 'name = "custom"', + "base_url = \"https://su'us.codes/v1\"", + 'wire_api = "responses"', + 'requires_openai_auth = true', + "", + ].join("\n"); + + const output = setCodexBaseUrl(input, "https://su'us'd.codes/v1"); + + expect(extractCodexBaseUrl(output)).toBe("https://su'us'd.codes/v1"); + expect(output.match(/^\s*base_url\s*=/gm)).toHaveLength(1); + expect(output).toContain("base_url = \"https://su'us'd.codes/v1\""); + }); + + it("collapses duplicate base_url lines when editing the active provider section", () => { + const input = [ + 'model_provider = "custom"', + 'model = "gpt-5.4"', + "", + "[model_providers.custom]", + 'name = "custom"', + 'base_url = "https://old.example/v1"', + 'base_url = "https://older.example/v1"', + 'wire_api = "responses"', + 'requires_openai_auth = true', + "", + ].join("\n"); + + const output = setCodexBaseUrl(input, "https://new.example/v1"); + + expect(extractCodexBaseUrl(output)).toBe("https://new.example/v1"); + expect(output.match(/^\s*base_url\s*=/gm)).toHaveLength(1); + expect(output).toContain('base_url = "https://new.example/v1"'); + expect(output).not.toContain("older.example"); + }); + it("reads and writes base_url in the active provider section", () => { const input = [ 'model_provider = "custom"',