Compare commits

..

7 Commits

Author SHA1 Message Date
YoVinchen e01ef2f51a Merge branch 'main' into feat/format-transform-refactor 2026-01-26 01:43:57 +08:00
YoVinchen dc8a70b14e feat(i18n): add format transform translations
Add translations for format transform configuration UI in Chinese,
English, and Japanese.
2026-01-25 19:59:07 +08:00
YoVinchen 4ee45d2bc3 feat(ui): add format transform configuration in provider form
- Add FormatTransformConfig type definition
- Add format transform section in ProviderAdvancedConfig component
- Support source/target format selection (Anthropic/OpenAI)
- Add transform streaming toggle option
2026-01-25 19:58:28 +08:00
YoVinchen be246f8596 fix(proxy): prevent duplicate endpoint in Codex adapter URL building
Fix URL construction when base_url already contains the endpoint path
(e.g., base_url="https://api.example.com/v1/chat/completions" with
endpoint="/v1/chat/completions" should not result in double path).
2026-01-25 19:57:50 +08:00
YoVinchen 6046c166cc feat(proxy): integrate format transform module in forwarder
- Use TransformConfig.from_provider() to get transform settings
- Add early validation for missing transformers
- Support transform_streaming flag to force non-streaming mode
- Transform endpoint path based on source/target format
2026-01-25 19:50:29 +08:00
YoVinchen fe4a968eef feat(provider): add FormatTransformConfig for API format conversion
Add FormatTransformConfig struct to support API format transformation
between different providers (e.g., Anthropic ↔ OpenAI). This enables
providers like OpenRouter that use OpenAI-compatible interfaces.
2026-01-25 19:46:22 +08:00
YoVinchen 55301abc00 refactor(proxy): extract format transform into standalone module
- Create new `proxy/transform/` module with clean architecture:
  - `traits.rs`: FormatTransformer trait definition
  - `format.rs`: ApiFormat enum (Anthropic, OpenAI, Gemini)
  - `registry.rs`: TransformerRegistry with global instance
  - `config.rs`: TransformConfig from Provider settings
  - `anthropic_openai/`: Bidirectional Anthropic ↔ OpenAI converters

- Move streaming transform from providers/ to transform/
- Remove legacy transform code from providers/adapter.rs and claude.rs
- Update handlers.rs to use new transform module

This refactor improves maintainability and makes it easier to add
new format converters in the future.
2026-01-25 19:37:30 +08:00
175 changed files with 3371 additions and 13131 deletions
+16 -27
View File
@@ -20,8 +20,6 @@ jobs:
include:
- os: windows-2022
- os: ubuntu-22.04
- os: ubuntu-22.04-arm
arch: arm64
- os: macos-14
steps:
@@ -59,8 +57,7 @@ jobs:
rpm \
flatpak \
flatpak-builder \
elfutils \
xdg-utils
elfutils
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev \
@@ -88,8 +85,8 @@ jobs:
uses: actions/cache@v4
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-
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-pnpm-store-
- name: Install frontend deps
run: pnpm install --frozen-lockfile
@@ -259,11 +256,10 @@ jobs:
set -euxo pipefail
mkdir -p release-assets
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
ARCH="${{ matrix.arch || 'x86_64' }}"
# Updater artifact: AppImage(含对应 .sig
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -n "$APPIMAGE" ]; then
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux-${ARCH}.AppImage"
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
echo "AppImage copied: $NEW_APPIMAGE"
@@ -273,16 +269,18 @@ jobs:
# 额外上传 .deb(用于手动安装,不参与 Updater)
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -n "$DEB" ]; then
cp "$DEB" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.deb"
echo "Deb package copied: CC-Switch-${VERSION}-Linux-${ARCH}.deb"
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
cp "$DEB" "release-assets/$NEW_DEB"
echo "Deb package copied: $NEW_DEB"
else
echo "No .deb found (optional)"
fi
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
if [ -n "$RPM" ]; then
cp "$RPM" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
echo "RPM package copied: CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
NEW_RPM="CC-Switch-${VERSION}-Linux.rpm"
cp "$RPM" "release-assets/$NEW_RPM"
echo "RPM package copied: $NEW_RPM"
else
echo "No .rpm found (optional)"
fi
@@ -314,8 +312,7 @@ jobs:
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-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`
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`Fedora/RHEL/openSUSE
---
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
@@ -363,8 +360,7 @@ jobs:
# 初始化空平台映射
mac_url=""; mac_sig=""
win_url=""; win_sig=""
linux_x64_url=""; linux_x64_sig=""
linux_arm64_url=""; linux_arm64_sig=""
linux_url=""; linux_sig=""
shopt -s nullglob
for sig in dl/*.sig; do
base=${sig%.sig}
@@ -375,10 +371,8 @@ jobs:
*.tar.gz)
# 视为 macOS updater artifact
mac_url="$url"; mac_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";;
*.AppImage|*.appimage)
linux_url="$url"; linux_sig="$sig_content";;
*.msi|*.exe)
win_url="$url"; win_sig="$sig_content";;
esac
@@ -405,14 +399,9 @@ jobs:
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
first=0
fi
if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-x86_64\": {\"signature\": \"$linux_x64_sig\", \"url\": \"$linux_x64_url\"}"
first=0
fi
if [ -n "$linux_arm64_url" ] && [ -n "$linux_arm64_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-aarch64\": {\"signature\": \"$linux_arm64_sig\", \"url\": \"$linux_arm64_url\"}"
echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}"
first=0
fi
echo ' }'
+1 -1
View File
@@ -8,7 +8,7 @@ release/
*.tsbuildinfo
.npmrc
CLAUDE.md
# AGENTS.md
AGENTS.md
GEMINI.md
/.claude
/.codex
-53
View File
@@ -7,59 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Windows Home Dir Regression**: Prevent providers/settings “disappearing” after upgrading from v3.10.2 → v3.10.3 when `HOME` differs from the real user profile directory; restore default path resolution and auto-detect the v3.10.3 legacy database location.
---
## [3.10.3] - 2026-01-30
### Feature Release
This release introduces a generic API format selector, pricing configuration enhancements, and multiple UX improvements.
### Added
- **API Key Link for OpenCode**: API key link support for OpenCode provider form, enabling quick access to provider key management pages
- **AICodeMirror Partner Preset**: Added AICodeMirror partner preset for all apps (Claude, Codex, Gemini, OpenCode)
- **API Format Selector**: Generic API format chooser for Claude providers, replacing the OpenRouter-specific toggle. Supports Anthropic Messages (native) and OpenAI Chat Completions format
- **API Format Presets**: Allow preset providers to specify API format (anthropic or openai_chat) for third-party proxy services
- **Proxy Hint**: Display info toast when switching to OpenAI Chat format provider, reminding users to enable proxy
- **Pricing Config Enhancement**: Per-provider cost multiplier, pricing model source (request/response), request model logging, and enriched usage UI (#781)
- **Skills ZIP Install**: Install skills directly from local ZIP files with recursive scanning support
- **Preferred Terminal**: Choose preferred terminal app per platform (macOS: Terminal.app/iTerm2/Alacritty/Kitty/Ghostty; Windows: cmd/PowerShell/Windows Terminal; Linux: GNOME Terminal/Konsole/Xfce4/Alacritty/Kitty/Ghostty)
- **Silent Startup**: Option to prevent window popup on launch (#713)
- **OpenCode Environment Check**: Version detection with Go path scanning and one-click install from GitHub Releases
- **OpenCode Directory Sync**: Auto-sync all providers to live config on directory change with additive mode support
- **NVIDIA NIM Preset**: New provider preset for Claude and OpenCode with nvidia.svg icon
- **n1n.ai Preset**: New provider preset (#667)
- **Update Badge Icon**: Replace update badge dot with ArrowUpCircle icon
- **Linux ARM64**: CI build support for Linux ARM64 architecture
### Changed
- **API Format Migration**: Migrate api_format from settings_config to ProviderMeta to prevent polluting ~/.claude/settings.json
- **DeepSeek max_tokens**: Remove max_tokens clamp from proxy transform layer
- **Terminal Functions**: Consolidate redundant terminal launch functions
- **Home Dir Utility**: Consolidate get_home_dir into single public function
- **Kimi/Moonshot**: Upgrade provider presets to k2.5 model
### Fixed
- **Codex 404 & Timeout**: Fix 404 errors and connection timeout with custom base_url; improve /v1 prefix handling and system proxy detection (#760)
- **Proxy URL Building**: Fix duplicate /v1/v1 in URL; extend ?beta=true to /v1/chat/completions endpoint
- **OpenRouter Compat Mode**: Improve backward compatibility supporting number and string types
- **Gemini Visibility**: Correct Gemini default visibility to true (#818)
- **Footer Layout**: Correct footer layout in advanced settings tab
- **Claude Code Detection**: Prioritize native install path for detection
- **Tray Menu**: Simplify title labels and optimize menu separators (#796)
- **Duplicate Skills**: Prevent duplicate skill installation from different repos (#778)
- **Windows Tests**: Stabilize test environment (#644)
- **i18n**: Update apiFormatOpenAIChat label to mention proxy requirement
- **Error Display**: Use extractErrorMessage for complete error display in mutations
- **Sponsors**: Add AICodeMirror and reorder sponsor list
---
## [3.10.2] - 2026-01-24
+2 -13
View File
@@ -32,25 +32,14 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
</tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.sh/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
</tr>
</table>
+2 -13
View File
@@ -32,25 +32,14 @@
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
</tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.sh/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります</td>
</tr>
</table>
+2 -14
View File
@@ -31,18 +31,6 @@
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
@@ -50,8 +38,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
</tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>感谢 AICoding.sh 赞助本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.sh/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
</tr>
</table>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

@@ -1,162 +0,0 @@
/**
* 统一供应商(Universal Provider)预设配置
*
* 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。
* 适用于 NewAPI 等支持多种协议的 API 网关。
*/
import type {
UniversalProvider,
UniversalProviderApps,
UniversalProviderModels,
} from "@/types";
/**
* 统一供应商预设接口
*/
export interface UniversalProviderPreset {
/** 预设名称 */
name: string;
/** 供应商类型标识 */
providerType: string;
/** 默认启用的应用 */
defaultApps: UniversalProviderApps;
/** 默认模型配置 */
defaultModels: UniversalProviderModels;
/** 网站链接 */
websiteUrl?: string;
/** 图标名称 */
icon?: string;
/** 图标颜色 */
iconColor?: string;
/** 描述 */
description?: string;
/** 是否为自定义模板(允许用户完全自定义) */
isCustomTemplate?: boolean;
}
/**
* NewAPI 默认模型配置
*/
const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
claude: {
model: "claude-sonnet-4-20250514",
haikuModel: "claude-haiku-4-20250514",
sonnetModel: "claude-sonnet-4-20250514",
opusModel: "claude-sonnet-4-20250514",
},
codex: {
model: "gpt-4o",
reasoningEffort: "high",
},
gemini: {
model: "gemini-2.5-pro",
},
};
const N1N_DEFAULT_MODELS: UniversalProviderModels = {
claude: {
model: "claude-3-5-sonnet-20240620",
haikuModel: "claude-3-haiku-20240307",
sonnetModel: "claude-3-5-sonnet-20240620",
opusModel: "claude-3-opus-20240229",
},
codex: {
model: "gpt-4o",
reasoningEffort: "high",
},
gemini: {
model: "gemini-1.5-pro-latest",
},
};
/**
* 统一供应商预设列表
*/
export const universalProviderPresets: UniversalProviderPreset[] = [
{
name: "n1n.ai",
providerType: "n1n",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: N1N_DEFAULT_MODELS,
websiteUrl: "https://n1n.ai",
icon: "openai",
iconColor: "#000000",
description:
"n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台",
},
{
name: "NewAPI",
providerType: "newapi",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
websiteUrl: "https://www.newapi.pro",
icon: "newapi",
iconColor: "#00A67E",
description:
"NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议",
},
{
name: "自定义网关",
providerType: "custom_gateway",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
icon: "openai",
iconColor: "#6366F1",
description: "自定义配置的 API 网关",
isCustomTemplate: true,
},
];
/**
* 根据预设创建统一供应商
*/
export function createUniversalProviderFromPreset(
preset: UniversalProviderPreset,
id: string,
baseUrl: string,
apiKey: string,
customName?: string,
): UniversalProvider {
return {
id,
name: customName || preset.name,
providerType: preset.providerType,
apps: { ...preset.defaultApps },
baseUrl,
apiKey,
models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy
websiteUrl: preset.websiteUrl,
icon: preset.icon,
iconColor: preset.iconColor,
createdAt: Date.now(),
};
}
/**
* 获取预设的显示名称(用于 UI)
*/
export function getPresetDisplayName(preset: UniversalProviderPreset): string {
return preset.name;
}
/**
* 根据类型查找预设
*/
export function findPresetByType(
providerType: string,
): UniversalProviderPreset | undefined {
return universalProviderPresets.find((p) => p.providerType === providerType);
}
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.10.3",
"version": "3.10.2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
@@ -57,12 +57,10 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.90.3",
"@tauri-apps/api": "^2.8.0",
@@ -74,7 +72,6 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"flexsearch": "^0.8.212",
"framer-motion": "^12.23.25",
"i18next": "^25.5.2",
"jsonc-parser": "^3.2.1",
-97
View File
@@ -59,9 +59,6 @@ importers:
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -74,9 +71,6 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -110,9 +104,6 @@ importers:
codemirror:
specifier: ^6.0.2
version: 6.0.2
flexsearch:
specifier: ^0.8.212
version: 0.8.212
framer-motion:
specifier: ^12.23.25
version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1126,19 +1117,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.10':
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -1196,19 +1174,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@@ -1358,67 +1323,56 @@ packages:
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.46.2':
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.46.2':
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.46.2':
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.46.2':
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.46.2':
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.46.2':
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.46.2':
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
@@ -1475,35 +1429,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.8.1':
resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.8.1':
resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.8.1':
resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.8.1':
resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.8.1':
resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==}
@@ -2046,9 +1995,6 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
flexsearch@0.8.212:
resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
@@ -2265,28 +2211,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -3963,23 +3905,6 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.1
@@ -4054,26 +3979,6 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -4853,8 +4758,6 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
flexsearch@0.8.212: {}
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
-268
View File
@@ -1,268 +0,0 @@
# 会话管理(Session Manager)需求文档(PRD / Markdown
> 目标:对 **Codex / Claude Code** 的本地会话记录进行可视化管理,并提供“一键复制 / 一键终端恢复”能力。
> 范围:**v1 仅 macOS**,但必须预留多平台扩展入口。
---
## 1. 背景与问题
开发者同时使用 Codex CLI、Claude Code 时,常见痛点:
- 会话记录落在本地不同位置,**难以发现/检索**
- 找到会话后,恢复命令需要记忆或翻历史,**恢复成本高**
- 恢复时经常忘了当时的工作目录,导致命令在错误目录运行
- 希望在常用终端(macOS Terminal、kitty 等)中直接恢复,提高效率
---
## 2. 目标与非目标
### 2.1 Goalsv1 必达)
1. 扫描并展示本机所有 Codex / Claude Code 会话:列表 + 详情(会话内容)
2. 支持恢复会话:
- 复制恢复命令(按钮)
- 复制会话目录(按钮,若能获取/推断)
- 可选:直接在终端执行恢复(macOS Terminal、kitty;可扩展)
3. 仅 macOS 支持,但代码结构需支持未来扩展 Windows/Linux
### 2.2 Non-Goalsv1 不做)
- 不新增/依赖云端 API;默认不上传任何内容
- 不承诺解析所有 provider 的全部内部格式(尽量兼容、可配置、可降级)
- 不做复杂的团队协作/分享/同步(后续版本再考虑)
---
## 3. 用户画像与使用场景
### 3.1 典型用户
- 高频使用多个 AI 编程工具的工程师/技术负责人/PM
- 多项目、多分支并行,频繁“中断—恢复—继续推进”
### 3.2 核心场景(Top
1. **找回会话**:我记得一个会话讨论过某段逻辑 → 搜索关键词 → 打开详情
2. **快速恢复**:我想继续昨天的会话 → 复制恢复命令 / 一键在终端恢复
3. **回到正确目录**:恢复前先复制目录或自动 cd 到目录
---
## 4. 产品形态与信息架构
### 4.1 信息架构
- Session Manager
- 会话列表(List
- 会话详情(Detail
- 设置(Settings
- Provider 配置(路径/启用禁用)
- 终端集成(默认终端、权限提示、降级策略)
- 索引与隐私选项(是否缓存、缓存大小、敏感信息遮罩)
---
## 5. 功能需求(Functional Requirements
### 5.1 会话发现与索引(Discovery & Indexing
**FR-1** 扫描本地会话数据源,生成统一的 Session 列表
- 支持 ProviderCodex、Claude Code(可扩展)
- 支持全量扫描 + 增量更新
- 支持缺失/异常文件的容错(不中断 UI)
**FR-2** 本地索引(Cache/DB
- 用于加速列表加载与搜索
- 索引字段至少包含:sessionId、provider、lastActiveAt、projectDir(可空)、summary(可空)、filePath(可空)
**FR-3** 数据源路径探测(可配置 + 多候选)
- 默认使用常见路径;允许用户在 Settings 覆盖
- 若无法探测到 provider 安装/数据目录:在 UI 显示未启用/不可用状态,但不报错崩溃
---
### 5.2 会话列表(List
**FR-4** 列表展示字段(建议最小集)
- ProviderCodex / Claude
- Session 标识(id/short id
- 最近活跃时间(lastActiveAt
- 目录(projectDir,若未知显示 “Unknown”)
- 摘要(summary:最后一条/首条截断或规则生成)
**FR-5** 列表交互
- 搜索(跨会话,关键词匹配 transcript/summary/目录)
- 过滤:Provider、是否有目录、时间范围
- 排序:最近活跃(默认)、最早、按目录
**FR-6** 空态/异常态
- 未发现任何会话:给出“如何启用/设置路径”的指引
- 发现会话但无法解析内容:列表仍可显示基本信息,并在详情页提示“解析失败”
---
### 5.3 会话详情(Detail
**FR-7** 会话内容展示
- 时间线展示消息(roleuser/assistant/tool 等)
- 支持在当前会话内搜索 + 高亮
- 展示元信息:
- provider、sessionId、创建/最近活跃时间
- projectDir(可空)
- 原始文件路径(可选显示,便于 debug)
**FR-8** 性能策略
- 默认按需加载(打开详情才加载全文)
- 对超长 transcript 支持分页/虚拟列表(防止卡顿)
---
### 5.4 恢复能力(Resume / Restore
#### 5.4.1 复制恢复命令(必做)
**FR-9** “复制恢复命令”按钮
- 根据 provider 生成恢复命令(模板可配置)
- 点击后写入剪贴板,并 toast 提示成功
> 说明:不同版本 CLI 命令可能略有差异,建议将命令模板做成可配置项(Settings),默认提供推荐模板。
#### 5.4.2 复制会话目录(尽量做)
**FR-10** “复制会话目录”按钮
- 当 projectDir 可得时启用;不可得时置灰,并提示原因(无法推断目录)
- 复制内容为可直接 `cd` 的绝对路径(或原样)
#### 5.4.3 一键终端恢复(可选但强烈建议)
**FR-11** “在终端恢复”按钮(或下拉菜单)
- 默认目标:macOS Terminal
- 支持 kittyv1 要求)
- 执行策略:
- `cd "<projectDir>" && <resumeCommand>`(若 projectDir 为空则仅执行 resumeCommand
- 失败降级:
- 无权限/终端不可用 → 自动降级为“仅复制命令”,并提示用户如何修复(例如开启 Automation 权限、kitty remote control
**FR-12** 终端目标选择与记忆
- 下拉选择:Terminal / kitty /(预留 iTerm2/ 仅复制
- 记住上次选择作为默认
---
## 6. 平台与扩展性设计(macOS v1 + Future-proof
### 6.1 Provider Adapter 抽象(必须)
统一接口(示例):
- `detect(): boolean`
- `scanSessions(): SessionMeta[]`
- `loadTranscript(sessionId): Message[]`
- `getResumeCommand(sessionId): string`
- `getProjectDir(sessionId): string | null`
### 6.2 Terminal Launcher 抽象(必须)
- `launch(command: string, cwd?: string, targetTerminal: TerminalKind): Result`
- macOS v1 实现:TerminalLauncherMac
- FutureTerminalLauncherWindows / TerminalLauncherLinux
### 6.3 Path Resolver(必须)
- `resolveProviderDataPaths(providerId): string[]`
- v1 返回 macOS 默认候选;允许 Settings 覆盖
---
## 7. 隐私与安全(Privacy & Security
**默认原则:全本地、只读、不上传。**
- transcript 默认不出网
- 本地索引默认仅存必要字段(可选:是否缓存全文内容)
- 提供“敏感信息遮罩”(可选):
- 简单正则:token/key/password 等
- 提示用户:会话内容可能包含敏感信息,导出/复制时注意
---
## 8. 非功能需求(Non-Functional Requirements
### 8.1 性能
- 首次打开:列表可在 1s 内展示(允许先展示缓存,再后台增量刷新)
- 搜索:在 1k 会话量级可用(建立索引或增量缓存)
- 详情页:打开后 300ms 内渲染骨架屏,内容流式/分段加载
### 8.2 稳定性
- 任一 provider 数据源损坏不影响整体(隔离失败)
- 扫描过程可中断/可重试
### 8.3 可观测性(可选)
- 本地日志:扫描耗时、解析失败原因、终端启动失败原因(便于 debug)
---
## 9. 关键数据结构(建议)
### 9.1 SessionMeta
- `providerId: "codex" | "claude" | string`
- `sessionId: string`
- `title?: string`
- `summary?: string`
- `projectDir?: string | null`
- `createdAt?: number`
- `lastActiveAt?: number`
- `sourcePath?: string`
### 9.2 Message
- `role: "user" | "assistant" | "tool" | "system" | string`
- `content: string`
- `ts?: number`
- `raw?: any`(保留原始字段,便于兼容未来格式)
---
## 10. 交互流程(UX Flows
### 10.1 Flow A:搜索并查看
1) 打开 Session Manager → 看到列表
2) 输入关键词搜索 → 命中会话
3) 点击会话 → 进入详情 → 浏览内容 / 在会话内搜索
### 10.2 Flow B:复制恢复命令
1) 列表或详情页点击“复制恢复命令”
2) toast 成功 → 用户粘贴到终端执行
### 10.3 Flow C:一键终端恢复
1) 详情页点击“在终端恢复”(默认 Terminal)
2) 系统打开终端新窗口/新 tab
3) 自动执行:`cd projectDir && resumeCommand`
4) 失败 → toast 提示,并提供“复制命令”降级路径
---
## 11. 边界情况与降级策略
- 无法获取 projectDir:仍可恢复(只执行 resume),目录按钮置灰
- 无法解析 transcript:列表仍显示,详情提示“无法解析”,可提供“打开原始文件路径”
- CLI 命令模板不匹配:允许 Settings 自定义模板;默认模板可更新
- 终端权限问题(Automation):提示用户在系统设置中开启对应权限,并允许降级为复制命令
- kitty 未开启 remote control:提示如何配置,降级为复制命令
---
## 12. 里程碑与交付(建议)
### M1(核心可用)
- Provider 扫描:Codex / Claude
- 列表 + 详情(可读)
- 复制恢复命令
- 复制目录(若可得)
### M2(效率提升)
- 跨会话搜索、过滤/排序
- 增量索引与文件监听(可选)
- “在 macOS Terminal 恢复”
### M3(终端覆盖与可扩展)
- “在 kitty 恢复”
- 终端目标下拉与记忆
- 插件化接口/扩展点文档
---
## 13. 后续功能候选(Backlog / Ideas
- 收藏/Pin 会话
- 会话标签(项目/主题/状态)
- 会话摘要(本地生成)
- Fork 会话继续(避免污染原会话)
- 导出 Markdown/JSONL
- 按项目聚合(Repo 视图)
- 会话清理/归档(磁盘管理)
---
+1 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.10.3"
version = "3.10.2"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.10.3"
version = "3.10.2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+1 -26
View File
@@ -1,28 +1,3 @@
fn main() {
tauri_build::build();
// Windows: Embed Common Controls v6 manifest for test binaries
//
// When running `cargo test`, the generated test executables don't include
// the standard Tauri application manifest. Without Common Controls v6,
// `tauri::test` calls fail with STATUS_ENTRYPOINT_NOT_FOUND.
//
// This workaround:
// 1. Embeds the manifest into test binaries via /MANIFEST:EMBED
// 2. Uses /MANIFEST:NO for the main binary to avoid duplicate resources
// (Tauri already handles manifest embedding for the app binary)
#[cfg(target_os = "windows")]
{
let manifest_path = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR"),
)
.join("common-controls.manifest");
let manifest_arg = format!("/MANIFESTINPUT:{}", manifest_path.display());
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg={}", manifest_arg);
// Avoid duplicate manifest resources in binary builds.
println!("cargo:rustc-link-arg-bins=/MANIFEST:NO");
println!("cargo:rerun-if-changed={}", manifest_path.display());
}
tauri_build::build()
}
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
</assembly>
-19
View File
@@ -282,25 +282,6 @@ impl AppType {
AppType::OpenCode => "opencode",
}
}
/// Check if this app uses additive mode
///
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
/// - Additive mode (true): All providers are written to live config (OpenCode)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode)
}
/// Return an iterator over all app types
pub fn all() -> impl Iterator<Item = AppType> {
[
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
]
.into_iter()
}
}
impl FromStr for AppType {
+9 -2
View File
@@ -2,14 +2,21 @@
use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file,
write_text_file,
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
};
use crate::error::AppError;
use serde_json::Value;
use std::fs;
use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
+24 -13
View File
@@ -9,6 +9,7 @@ use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
/// 获取 Claude Code 配置状态
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
Ok(config::get_claude_config_status())
@@ -62,11 +63,13 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
}
}
/// 获取 Claude Code 配置文件路径
#[tauri::command]
pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_string())
}
/// 获取当前生效的配置目录
#[tauri::command]
pub async fn get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -79,6 +82,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
Ok(dir.to_string_lossy().to_string())
}
/// 打开配置文件夹
#[tauri::command]
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -100,6 +104,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: AppHandle,
@@ -131,12 +136,14 @@ pub async fn pick_directory(
}
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
let config_path = config::get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
let config_dir = config::get_app_config_dir();
@@ -153,6 +160,7 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
Ok(true)
}
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet
#[tauri::command]
pub async fn get_claude_common_config_snippet(
state: tauri::State<'_, crate::store::AppState>,
@@ -163,11 +171,13 @@ pub async fn get_claude_common_config_snippet(
.map_err(|e| e.to_string())
}
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet
#[tauri::command]
pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
// 验证是否为有效的 JSON(如果不为空)
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
}
@@ -185,6 +195,7 @@ pub async fn set_claude_common_config_snippet(
Ok(())
}
/// 获取通用配置片段(统一接口)
#[tauri::command]
pub async fn get_common_config_snippet(
app_type: String,
@@ -196,19 +207,25 @@ pub async fn get_common_config_snippet(
.map_err(|e| e.to_string())
}
/// 设置通用配置片段(统一接口)
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" | "omo" => {
"claude" | "gemini" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"codex" => {}
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate)
// 注意:TOML 验证较为复杂,暂时跳过
}
_ => {}
}
}
@@ -223,20 +240,14 @@ pub async fn set_common_config_snippet(
.db
.set_config_snippet(&app_type, value)
.map_err(|e| e.to_string())?;
if app_type == "omo"
&& state
.db
.get_current_omo_provider("opencode")
.map_err(|e| e.to_string())?
.is_some()
{
crate::services::OmoService::write_config_to_file(state.inner())
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// 提取通用配置片段
///
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
///
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
#[tauri::command]
pub async fn extract_common_config_snippet(
appType: String,
-14
View File
@@ -109,17 +109,3 @@ pub async fn open_file_dialog<R: tauri::Runtime>(
Ok(result.map(|p| p.to_string()))
}
/// 打开 ZIP 文件选择对话框
#[tauri::command]
pub async fn open_zip_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<String>, String> {
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("ZIP", &["zip"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
}
+37 -251
View File
@@ -89,7 +89,7 @@ pub struct ToolVersion {
#[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini", "opencode"];
let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new();
// 使用全局 HTTP 客户端(已包含代理配置)
@@ -116,7 +116,6 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
_ => None,
};
@@ -149,29 +148,6 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
}
}
/// Helper function to fetch latest version from GitHub releases
async fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option<String> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
match client
.get(&url)
.header("User-Agent", "cc-switch")
.header("Accept", "application/vnd.github+json")
.send()
.await
{
Ok(resp) => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
json.get("tag_name")
.and_then(|v| v.as_str())
.map(|s| s.strip_prefix('v').unwrap_or(s).to_string())
} else {
None
}
}
Err(_) => None,
}
}
/// 预编译的版本号正则表达式
static VERSION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
@@ -248,7 +224,7 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
// 防御性断言:tool 只能是预定义的值
debug_assert!(
["claude", "codex", "gemini", "opencode"].contains(&tool),
["claude", "codex", "gemini"].contains(&tool),
"unexpected tool name: {tool}"
);
@@ -319,12 +295,11 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
let home = dirs::home_dir().unwrap_or_default();
// 常见的安装路径(原生安装优先)
// 常见的 npm 全局安装路径
let mut search_paths: Vec<std::path::PathBuf> = vec![
home.join(".local/bin"), // Native install (official recommended)
home.join(".npm-global/bin"),
home.join(".local/bin"),
home.join("n/bin"), // n version manager
home.join(".volta/bin"), // Volta package manager
];
#[cfg(target_os = "macos")]
@@ -373,14 +348,6 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
}
}
// 添加 Go 路径支持 (opencode 使用 go install 安装)
if tool == "opencode" {
search_paths.push(home.join("go/bin")); // go install 默认路径
if let Ok(gopath) = std::env::var("GOPATH") {
search_paths.push(std::path::PathBuf::from(gopath).join("bin"));
}
}
// 在每个路径中查找工具
for path in &search_paths {
let tool_path = if cfg!(target_os = "windows") {
@@ -438,7 +405,6 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
"opencode" => crate::settings::get_opencode_override_dir(),
_ => None,
}?;
@@ -615,19 +581,18 @@ fn write_claude_config(
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// macOS: 根据用户首选终端启动
/// macOS: 使用 Terminal.app 启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");
use std::process::Command;
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();
// Write the shell script to a temp file
// Write the shell script to a temp file (no escaping needed!)
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
@@ -646,35 +611,7 @@ exec bash --norc --noprofile
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Try the preferred terminal first, fall back to Terminal.app if it fails
// Note: Kitty doesn't need the -e flag, others do
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
};
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
if result.is_err() && terminal != "terminal" {
log::warn!(
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
terminal,
result.as_ref().err()
);
return launch_macos_terminal_app(&script_file);
}
result
}
/// macOS: Terminal.app
#[cfg(target_os = "macos")]
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
// Simple AppleScript - just execute the script file
let applescript = format!(
r#"tell application "Terminal"
activate
@@ -690,9 +627,12 @@ end tell"#,
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&script_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"Terminal.app 执行失败 (exit code: {:?}): {}",
"AppleScript 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
@@ -701,86 +641,13 @@ end tell"#,
Ok(())
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = format!(
r#"tell application "iTerm"
activate
tell current window
create tab with default profile
tell current session
write text "bash '{}'"
end tell
end tell
end tell"#,
script_file.display()
);
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"iTerm2 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
/// macOS: 使用 open -a 启动支持 --args 参数的终端(Alacritty/Kitty/Ghostty
#[cfg(target_os = "macos")]
fn launch_macos_open_app(
app_name: &str,
script_file: &std::path::Path,
use_e_flag: bool,
) -> Result<(), String> {
use std::process::Command;
let mut cmd = Command::new("open");
cmd.arg("-a").arg(app_name).arg("--args");
if use_e_flag {
cmd.arg("-e");
}
cmd.arg("bash").arg(script_file);
let output = cmd
.output()
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
app_name,
output.status.code(),
stderr
));
}
Ok(())
}
/// Linux: 根据用户首选终端启动
/// Linux: 尝试使用常见终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let preferred = crate::settings::get_preferred_terminal();
// Default terminal list with their arguments
let default_terminals = [
let terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
@@ -788,10 +655,9 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
("ghostty", vec!["-e"]),
];
// Create temp script file
// Create temp script file (same approach as macOS)
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();
@@ -813,48 +679,25 @@ exec bash --norc --noprofile
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Build terminal list: preferred terminal first (if specified), then defaults
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
// Find the preferred terminal's args from default list
let pref_args = default_terminals
.iter()
.find(|(name, _)| *name == pref.as_str())
.map(|(_, args)| args.iter().map(|s| *s).collect::<Vec<&str>>())
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
let mut list = vec![(pref.as_str(), pref_args)];
// Add remaining terminals as fallbacks
for (name, args) in &default_terminals {
if *name != pref.as_str() {
list.push((*name, args.iter().map(|s| *s).collect()));
}
}
list
} else {
default_terminals
.iter()
.map(|(name, args)| (*name, args.iter().map(|s| *s).collect()))
.collect()
};
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals_to_try {
// Check if terminal exists in common paths
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
for (terminal, args) in terminals {
// Check if terminal exists
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists()
|| which_command(terminal);
if terminal_exists {
{
let result = Command::new(terminal)
.args(&args)
.arg("bash")
.arg(script_file.to_string_lossy().as_ref())
.spawn();
.output();
match result {
Ok(_) => return Ok(()),
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
last_error = format!("启动 {} 失败: {}", terminal, stderr);
}
Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e);
}
@@ -868,25 +711,13 @@ exec bash --norc --noprofile
Err(last_error)
}
/// Check if a command exists using `which`
#[cfg(target_os = "linux")]
fn which_command(cmd: &str) -> bool {
use std::process::Command;
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Windows: 根据用户首选终端启动
/// Windows: 创建临时批处理文件启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
use std::process::Command;
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
@@ -902,53 +733,23 @@ del \"%~f0\" >nul 2>&1
config_path_for_batch, config_path_for_batch, config_path_for_batch
);
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
// Try the preferred terminal first
let result = match terminal {
"powershell" => run_windows_start_command(
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
};
// If preferred terminal fails and it's not the default, try cmd as fallback
if result.is_err() && terminal != "cmd" {
log::warn!(
"首选终端 {} 启动失败,回退到 cmd: {:?}",
terminal,
result.as_ref().err()
);
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
}
result
}
/// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
use std::process::Command;
let mut full_args = vec!["/C", "start"];
full_args.extend(args);
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
// Use output() to capture errors from the start command
// Use /K instead of /C to keep the window open after execution
let output = Command::new("cmd")
.args(&full_args)
.args(["/C", "start", "cmd", "/K", &bat_file.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
.map_err(|e| format!("执行 cmd 失败: {e}"))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&bat_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
terminal_name,
"启动 Windows 终端失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
@@ -956,18 +757,3 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
Ok(())
}
/// 设置窗口主题(Windows/macOS 标题栏颜色)
/// theme: "dark" | "light" | "system"
#[tauri::command]
pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<(), String> {
use tauri::Theme;
let tauri_theme = match theme.as_str() {
"dark" => Some(Theme::Dark),
"light" => Some(Theme::Light),
_ => None, // system default
};
window.set_theme(tauri_theme).map_err(|e| e.to_string())
}
-4
View File
@@ -8,12 +8,10 @@ mod global_proxy;
mod import_export;
mod mcp;
mod misc;
mod omo;
mod plugin;
mod prompt;
mod provider;
mod proxy;
mod session_manager;
mod settings;
pub mod skill;
mod stream_check;
@@ -27,12 +25,10 @@ pub use global_proxy::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use omo::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
pub use proxy::*;
pub use session_manager::*;
pub use settings::*;
pub use skill::*;
pub use stream_check::*;
-50
View File
@@ -1,50 +0,0 @@
use tauri::State;
use crate::services::omo::OmoLocalFileData;
use crate::services::OmoService;
use crate::store::AppState;
#[tauri::command]
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
OmoService::read_local_file().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
let provider = state
.db
.get_current_omo_provider("opencode")
.map_err(|e| e.to_string())?;
Ok(provider.map(|p| p.id).unwrap_or_default())
}
#[tauri::command]
pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
for (id, p) in &providers {
if p.category.as_deref() == Some("omo") {
state
.db
.clear_omo_provider_current("opencode", id)
.map_err(|e| e.to_string())?;
}
}
OmoService::delete_config_file().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
let count = providers
.values()
.filter(|p| p.category.as_deref() == Some("omo"))
.count();
Ok(count)
}
+47 -6
View File
@@ -8,6 +8,7 @@ use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, Spee
use crate::store::AppState;
use std::str::FromStr;
/// 获取所有供应商
#[tauri::command]
pub fn get_providers(
state: State<'_, AppState>,
@@ -17,12 +18,14 @@ pub fn get_providers(
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 获取当前供应商ID
#[tauri::command]
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 添加供应商
#[tauri::command]
pub fn add_provider(
state: State<'_, AppState>,
@@ -33,6 +36,7 @@ pub fn add_provider(
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 更新供应商
#[tauri::command]
pub fn update_provider(
state: State<'_, AppState>,
@@ -43,6 +47,7 @@ pub fn update_provider(
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 删除供应商
#[tauri::command]
pub fn delete_provider(
state: State<'_, AppState>,
@@ -55,18 +60,17 @@ pub fn delete_provider(
.map_err(|e| e.to_string())
}
/// Remove provider from live config only (for additive mode apps like OpenCode)
/// Does NOT delete from database - provider remains in the list
#[tauri::command]
pub fn remove_provider_from_live_config(
state: tauri::State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_from_live_config(state.inner(), app_type, &id)
ProviderService::remove_from_live_config(app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 切换供应商
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
ProviderService::switch(state, app_type, id)
}
@@ -104,12 +108,14 @@ pub fn import_default_config_test_hook(
import_default_config_internal(state, app_type)
}
/// 导入当前配置为默认供应商
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type).map_err(Into::into)
}
/// 查询供应商用量
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
@@ -123,6 +129,7 @@ pub async fn queryProviderUsage(
.map_err(|e| e.to_string())
}
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
#[allow(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command]
@@ -155,12 +162,14 @@ pub async fn testUsageScript(
.map_err(|e| e.to_string())
}
/// 读取当前生效的配置内容
#[tauri::command]
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
}
/// 测试第三方/自定义供应商端点的网络延迟
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
@@ -171,6 +180,7 @@ pub async fn test_api_endpoints(
.map_err(|e| e.to_string())
}
/// 获取自定义端点列表
#[tauri::command]
pub fn get_custom_endpoints(
state: State<'_, AppState>,
@@ -182,6 +192,7 @@ pub fn get_custom_endpoints(
.map_err(|e| e.to_string())
}
/// 添加自定义端点
#[tauri::command]
pub fn add_custom_endpoint(
state: State<'_, AppState>,
@@ -194,6 +205,7 @@ pub fn add_custom_endpoint(
.map_err(|e| e.to_string())
}
/// 删除自定义端点
#[tauri::command]
pub fn remove_custom_endpoint(
state: State<'_, AppState>,
@@ -206,6 +218,7 @@ pub fn remove_custom_endpoint(
.map_err(|e| e.to_string())
}
/// 更新端点最后使用时间
#[tauri::command]
pub fn update_endpoint_last_used(
state: State<'_, AppState>,
@@ -218,6 +231,7 @@ pub fn update_endpoint_last_used(
.map_err(|e| e.to_string())
}
/// 更新多个供应商的排序
#[tauri::command]
pub fn update_providers_sort_order(
state: State<'_, AppState>,
@@ -228,16 +242,24 @@ pub fn update_providers_sort_order(
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
}
// ============================================================================
// 统一供应商(Universal Provider)命令
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
/// 统一供应商同步完成事件的 payload
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
/// 操作类型: "upsert" | "delete" | "sync"
pub action: String,
/// 统一供应商 ID
pub id: String,
}
/// 发送统一供应商同步事件,通知前端刷新供应商列表
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
@@ -248,6 +270,7 @@ fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
);
}
/// 获取所有统一供应商
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
@@ -255,6 +278,7 @@ pub fn get_universal_providers(
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
/// 获取单个统一供应商
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
@@ -263,6 +287,7 @@ pub fn get_universal_provider(
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
/// 添加或更新统一供应商
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
@@ -273,11 +298,13 @@ pub fn upsert_universal_provider(
let result =
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "upsert", &id);
Ok(result)
}
/// 删除统一供应商
#[tauri::command]
pub fn delete_universal_provider(
app: AppHandle,
@@ -287,11 +314,13 @@ pub fn delete_universal_provider(
let result =
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "delete", &id);
Ok(result)
}
/// 同步统一供应商到各应用(手动触发)
#[tauri::command]
pub fn sync_universal_provider(
app: AppHandle,
@@ -301,17 +330,29 @@ pub fn sync_universal_provider(
let result =
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "sync", &id);
Ok(result)
}
// ============================================================================
// OpenCode 专属命令
// ============================================================================
/// 从 OpenCode live 配置导入供应商到数据库
///
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
/// 用户可能已经在 opencode.json 中配置了供应商。
#[tauri::command]
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_opencode_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// 获取 OpenCode live 配置中的供应商 ID 列表
///
/// 用于前端判断供应商是否已添加到 opencode.json
#[tauri::command]
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
crate::opencode_config::get_providers()
-115
View File
@@ -2,7 +2,6 @@
//!
//! 提供前端调用的 API 接口
use crate::error::AppError;
use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
@@ -120,120 +119,6 @@ pub async fn update_proxy_config_for_app(
.map_err(|e| e.to_string())
}
async fn get_default_cost_multiplier_internal(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
let db = &state.db;
db.get_default_cost_multiplier(app_type).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn get_default_cost_multiplier_test_hook(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
get_default_cost_multiplier_internal(state, app_type).await
}
/// 获取默认成本倍率
#[tauri::command]
pub async fn get_default_cost_multiplier(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<String, String> {
get_default_cost_multiplier_internal(&state, &app_type)
.await
.map_err(|e| e.to_string())
}
async fn set_default_cost_multiplier_internal(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let db = &state.db;
db.set_default_cost_multiplier(app_type, value).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn set_default_cost_multiplier_test_hook(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
set_default_cost_multiplier_internal(state, app_type, value).await
}
/// 设置默认成本倍率
#[tauri::command]
pub async fn set_default_cost_multiplier(
state: tauri::State<'_, AppState>,
app_type: String,
value: String,
) -> Result<(), String> {
set_default_cost_multiplier_internal(&state, &app_type, &value)
.await
.map_err(|e| e.to_string())
}
async fn get_pricing_model_source_internal(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
let db = &state.db;
db.get_pricing_model_source(app_type).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn get_pricing_model_source_test_hook(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
get_pricing_model_source_internal(state, app_type).await
}
/// 获取计费模式来源
#[tauri::command]
pub async fn get_pricing_model_source(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<String, String> {
get_pricing_model_source_internal(&state, &app_type)
.await
.map_err(|e| e.to_string())
}
async fn set_pricing_model_source_internal(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let db = &state.db;
db.set_pricing_model_source(app_type, value).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn set_pricing_model_source_test_hook(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
set_pricing_model_source_internal(state, app_type, value).await
}
/// 设置计费模式来源
#[tauri::command]
pub async fn set_pricing_model_source(
state: tauri::State<'_, AppState>,
app_type: String,
value: String,
) -> Result<(), String> {
set_pricing_model_source_internal(&state, &app_type, &value)
.await
.map_err(|e| e.to_string())
}
/// 检查代理服务器是否正在运行
#[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
-59
View File
@@ -1,59 +0,0 @@
#![allow(non_snake_case)]
use crate::session_manager;
#[tauri::command]
pub async fn list_sessions() -> Result<Vec<session_manager::SessionMeta>, String> {
let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions)
.await
.map_err(|e| format!("Failed to scan sessions: {e}"))?;
Ok(sessions)
}
#[tauri::command]
pub async fn get_session_messages(
providerId: String,
sourcePath: String,
) -> Result<Vec<session_manager::SessionMessage>, String> {
let provider_id = providerId.clone();
let source_path = sourcePath.clone();
tauri::async_runtime::spawn_blocking(move || {
session_manager::load_messages(&provider_id, &source_path)
})
.await
.map_err(|e| format!("Failed to load session messages: {e}"))?
}
#[tauri::command]
pub async fn launch_session_terminal(
command: String,
cwd: Option<String>,
custom_config: Option<String>,
) -> Result<bool, String> {
let command = command.clone();
let cwd = cwd.clone();
let custom_config = custom_config.clone();
// Read preferred terminal from global settings
let preferred = crate::settings::get_preferred_terminal();
// Map global setting terminal names to session terminal names
// Global uses "iterm2", session terminal uses "iterm"
let target = match preferred.as_deref() {
Some("iterm2") => "iterm".to_string(),
Some(t) => t.to_string(),
None => "terminal".to_string(), // Default to Terminal.app on macOS
};
tauri::async_runtime::spawn_blocking(move || {
session_manager::terminal::launch_terminal(
&target,
&command,
cwd.as_deref(),
custom_config.as_deref(),
)
})
.await
.map_err(|e| format!("Failed to launch terminal: {e}"))??;
Ok(true)
}
-13
View File
@@ -249,16 +249,3 @@ pub fn remove_skill_repo(
.map_err(|e| e.to_string())?;
Ok(true)
}
/// 从 ZIP 文件安装 Skills
#[tauri::command]
pub fn install_skills_from_zip(
file_path: String,
current_app: String,
app_state: State<'_, AppState>,
) -> Result<Vec<InstalledSkill>, String> {
let app_type = parse_app_type(&current_app)?;
let path = std::path::Path::new(&file_path);
SkillService::install_from_zip(&app_state.db, path, &app_type).map_err(|e| e.to_string())
}
+4 -48
View File
@@ -6,26 +6,7 @@ use std::path::{Path, PathBuf};
use crate::error::AppError;
/// 获取用户主目录,带回退和日志
///
/// ## Windows 注意事项
///
/// - `dirs::home_dir()` 在 Windows 上使用 `SHGetKnownFolderPath(FOLDERID_Profile)`
/// 返回的是真实用户目录(类似 `C:\\Users\\Alice`),与 v3.10.2 行为一致。
/// - 不要直接使用 `HOME` 环境变量:它可能由 Git/Cygwin/MSYS 等第三方工具注入,
/// 且不一定等于用户目录,可能导致 `.cc-switch/cc-switch.db` 路径变化,从而“看起来像数据丢失”。
///
/// ## 测试隔离
///
/// 为了让 Windows CI/本地测试能稳定隔离真实用户数据,可通过 `CC_SWITCH_TEST_HOME`
/// 显式覆盖 home dir(仅用于测试/调试场景)。
pub fn get_home_dir() -> PathBuf {
if let Ok(home) = std::env::var("CC_SWITCH_TEST_HOME") {
let trimmed = home.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
@@ -91,34 +72,9 @@ pub fn get_app_config_dir() -> PathBuf {
return custom;
}
let default_dir = get_home_dir().join(".cc-switch");
// 兼容 v3.10.3:当用户环境存在 `HOME` 且与真实用户目录不同,
// v3.10.3 可能在 `HOME/.cc-switch/` 下创建/使用了数据库。
// 这里仅在“默认位置没有数据库”时回退到旧位置,避免再次出现“供应商消失”问题,
// 同时也避免新安装因为 `HOME` 被设置而写入非预期路径。
#[cfg(windows)]
{
let default_db = default_dir.join("cc-switch.db");
if !default_db.exists() {
if let Ok(home_env) = std::env::var("HOME") {
let trimmed = home_env.trim();
if !trimmed.is_empty() {
let legacy_dir = PathBuf::from(trimmed).join(".cc-switch");
if legacy_dir.join("cc-switch.db").exists() {
log::info!(
"Detected v3.10.3 legacy database at {}, using it instead of {}",
legacy_dir.display(),
default_dir.display()
);
return legacy_dir;
}
}
}
}
}
default_dir
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
}
/// 获取应用配置文件路径
-2
View File
@@ -4,7 +4,6 @@
pub mod failover;
pub mod mcp;
pub mod omo;
pub mod prompts;
pub mod providers;
pub mod proxy;
@@ -16,4 +15,3 @@ pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
pub use failover::FailoverQueueItem;
pub use omo::OmoGlobalConfig;
-73
View File
@@ -1,73 +0,0 @@
use crate::database::Database;
use crate::error::AppError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OmoGlobalConfig {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sisyphus_agent: Option<serde_json::Value>,
#[serde(default)]
pub disabled_agents: Vec<String>,
#[serde(default)]
pub disabled_mcps: Vec<String>,
#[serde(default)]
pub disabled_hooks: Vec<String>,
#[serde(default)]
pub disabled_skills: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lsp: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_task: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_automation_engine: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_code: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub other_fields: Option<serde_json::Value>,
pub updated_at: String,
}
impl Default for OmoGlobalConfig {
fn default() -> Self {
Self {
id: "global".to_string(),
schema_url: None,
sisyphus_agent: None,
disabled_agents: vec![],
disabled_mcps: vec![],
disabled_hooks: vec![],
disabled_skills: vec![],
lsp: None,
experimental: None,
background_task: None,
browser_automation_engine: None,
claude_code: None,
other_fields: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}
}
}
impl Database {
pub fn get_omo_global_config(&self) -> Result<OmoGlobalConfig, AppError> {
let json_str = self.get_setting("common_config_omo")?;
match json_str {
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
.map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))),
None => Ok(OmoGlobalConfig::default()),
}
}
pub fn save_omo_global_config(&self, config: &OmoGlobalConfig) -> Result<(), AppError> {
let json_str = serde_json::to_string(config)
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
self.set_setting("common_config_omo", &json_str)?;
Ok(())
}
}
+24 -133
View File
@@ -1,3 +1,7 @@
//! 供应商数据访问对象
//!
//! 提供供应商(Provider)的 CRUD 操作。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta};
@@ -5,18 +9,8 @@ use indexmap::IndexMap;
use rusqlite::params;
use std::collections::HashMap;
type OmoProviderRow = (
String,
String,
String,
Option<String>,
Option<i64>,
Option<usize>,
Option<String>,
String,
);
impl Database {
/// 获取指定应用类型的所有供应商
pub fn get_all_providers(
&self,
app_type: &str,
@@ -72,6 +66,7 @@ impl Database {
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
provider.id = id.clone();
// 加载 endpoints
let mut stmt_endpoints = conn.prepare(
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -108,6 +103,7 @@ impl Database {
Ok(providers)
}
/// 获取当前激活的供应商 ID
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
@@ -127,6 +123,7 @@ impl Database {
}
}
/// 根据 ID 获取单个供应商
pub fn get_provider_by_id(
&self,
id: &str,
@@ -177,15 +174,21 @@ impl Database {
}
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
/// add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 处理 meta:取出 endpoints 以便单独处理
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue
let existing: Option<(bool, bool)> = tx
.query_row(
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
@@ -199,6 +202,7 @@ impl Database {
existing.unwrap_or((false, provider.in_failover_queue));
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
tx.execute(
"UPDATE providers SET
name = ?1,
@@ -237,6 +241,7 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
} else {
// 新增模式:使用 INSERT
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
@@ -263,6 +268,7 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 只有新增时才同步 endpoints
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
@@ -277,6 +283,7 @@ impl Database {
Ok(())
}
/// 删除供应商
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
@@ -287,18 +294,21 @@ impl Database {
Ok(())
}
/// 设置当前供应商
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 重置所有为 0
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 设置新的当前供应商
tx.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
@@ -309,6 +319,7 @@ impl Database {
Ok(())
}
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
pub fn update_provider_settings_config(
&self,
app_type: &str,
@@ -330,6 +341,7 @@ impl Database {
Ok(())
}
/// 添加自定义端点
pub fn add_custom_endpoint(
&self,
app_type: &str,
@@ -345,6 +357,7 @@ impl Database {
Ok(())
}
/// 移除自定义端点
pub fn remove_custom_endpoint(
&self,
app_type: &str,
@@ -359,126 +372,4 @@ impl Database {
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn set_omo_provider_current(
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
let updated = tx
.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
if updated != 1 {
return Err(AppError::Database(format!(
"Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'"
)));
}
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn is_omo_provider_current(
&self,
app_type: &str,
provider_id: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
match conn.query_row(
"SELECT is_current FROM providers
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
params![provider_id, app_type],
|row| row.get(0),
) {
Ok(is_current) => Ok(is_current),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
pub fn clear_omo_provider_current(
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET is_current = 0
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn get_current_omo_provider(&self, app_type: &str) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn);
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
FROM providers
WHERE app_type = ?1 AND category = 'omo' AND is_current = 1
LIMIT 1",
params![app_type],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
))
},
);
let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) =
match row_data {
Ok(v) => v,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => return Err(AppError::Database(e.to_string())),
};
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
AppError::Database(format!(
"Failed to parse OMO provider settings_config (provider_id={id}): {e}"
))
})?;
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
crate::provider::ProviderMeta::default()
} else {
serde_json::from_str(&meta_str).map_err(|e| {
AppError::Database(format!(
"Failed to parse OMO provider meta (provider_id={id}): {e}"
))
})?
};
Ok(Some(Provider {
id,
name,
settings_config,
website_url: None,
category,
created_at,
sort_index,
notes,
meta: Some(meta),
icon: None,
icon_color: None,
in_failover_queue: false,
}))
}
}
+7 -259
View File
@@ -4,7 +4,6 @@
use crate::error::AppError;
use crate::proxy::types::*;
use rust_decimal::Decimal;
use super::super::{lock_conn, Database};
@@ -76,117 +75,6 @@ impl Database {
Ok(())
}
/// 获取默认成本倍率
pub async fn get_default_cost_multiplier(&self, app_type: &str) -> Result<String, AppError> {
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT default_cost_multiplier FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
};
match result {
Ok(value) => Ok(value),
Err(rusqlite::Error::QueryReturnedNoRows) => {
self.init_proxy_config_rows().await?;
Ok("1".to_string())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 设置默认成本倍率
pub async fn set_default_cost_multiplier(
&self,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::localized(
"error.multiplierEmpty",
"倍率不能为空",
"Multiplier cannot be empty",
));
}
trimmed.parse::<Decimal>().map_err(|e| {
AppError::localized(
"error.invalidMultiplier",
format!("无效倍率: {value} - {e}"),
format!("Invalid multiplier: {value} - {e}"),
)
})?;
// 确保行存在
self.ensure_proxy_config_row_exists(app_type)?;
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
default_cost_multiplier = ?2,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![app_type, trimmed],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取计费模式来源
pub async fn get_pricing_model_source(&self, app_type: &str) -> Result<String, AppError> {
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT pricing_model_source FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
};
match result {
Ok(value) => Ok(value),
Err(rusqlite::Error::QueryReturnedNoRows) => {
self.init_proxy_config_rows().await?;
Ok("response".to_string())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 设置计费模式来源
pub async fn set_pricing_model_source(
&self,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let trimmed = value.trim();
if !matches!(trimmed, "response" | "request") {
return Err(AppError::localized(
"error.invalidPricingMode",
format!("无效计费模式: {value}"),
format!("Invalid pricing mode: {value}"),
));
}
// 确保行存在
self.ensure_proxy_config_row_exists(app_type)?;
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
pricing_model_source = ?2,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![app_type, trimmed],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取应用级代理配置
pub async fn get_proxy_config_for_app(
&self,
@@ -289,90 +177,17 @@ impl Database {
Ok(())
}
/// 确保指定 app_type 的 proxy_config 行存在(同步版本,用于 set_* 函数)
///
/// 使用与 schema.rs seed 相同的 per-app 默认值
fn ensure_proxy_config_row_exists(&self, app_type: &str) -> Result<(), AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Lock(e.to_string()))?;
// 根据 app_type 使用不同的默认值(与 schema.rs seed 保持一致)
let (retries, fb_timeout, idle_timeout, cb_fail, cb_succ, cb_timeout, cb_rate, cb_min) =
match app_type {
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
};
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
) VALUES (?1, ?2, ?3, ?4, 600, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
app_type,
retries,
fb_timeout,
idle_timeout,
cb_fail,
cb_succ,
cb_timeout,
cb_rate,
cb_min
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化 proxy_config 表的三行数据
///
/// 使用与 schema.rs seed 相同的 per-app 默认值
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 使用与 schema.rs seed 相同的 per-app 默认值
// claude: 更激进的重试和超时配置
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
) VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// codex: 默认配置
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
) VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// gemini: 稍高的重试次数
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
) VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
for app_type in &["claude", "codex", "gemini"] {
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
@@ -847,70 +662,3 @@ impl Database {
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::database::Database;
use crate::error::AppError;
#[tokio::test]
async fn test_default_cost_multiplier_round_trip() -> Result<(), AppError> {
let db = Database::memory()?;
let default = db.get_default_cost_multiplier("claude").await?;
assert_eq!(default, "1");
db.set_default_cost_multiplier("claude", "1.5").await?;
let updated = db.get_default_cost_multiplier("claude").await?;
assert_eq!(updated, "1.5");
Ok(())
}
#[tokio::test]
async fn test_default_cost_multiplier_validation() -> Result<(), AppError> {
let db = Database::memory()?;
let err = db
.set_default_cost_multiplier("claude", "not-a-number")
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(
err,
AppError::Localized {
key: "error.invalidMultiplier",
..
}
));
Ok(())
}
#[tokio::test]
async fn test_pricing_model_source_round_trip_and_validation() -> Result<(), AppError> {
let db = Database::memory()?;
let default = db.get_pricing_model_source("claude").await?;
assert_eq!(default, "response");
db.set_pricing_model_source("claude", "request").await?;
let updated = db.get_pricing_model_source("claude").await?;
assert_eq!(updated, "request");
let err = db
.set_pricing_model_source("claude", "invalid")
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(
err,
AppError::Localized {
key: "error.invalidPricingMode",
..
}
));
Ok(())
}
}
+1 -2
View File
@@ -33,7 +33,6 @@ mod tests;
// DAO 类型导出供外部使用
pub use dao::FailoverQueueItem;
pub use dao::OmoGlobalConfig;
use crate::config::get_app_config_dir;
use crate::error::AppError;
@@ -48,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 5;
pub(crate) const SCHEMA_VERSION: i32 = 4;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+10 -82
View File
@@ -120,8 +120,6 @@ impl Database {
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?;
@@ -172,7 +170,6 @@ impl Database {
// 10. Proxy Request Logs 表
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
request_model TEXT,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
@@ -355,11 +352,6 @@ impl Database {
Self::migrate_v3_to_v4(conn)?;
Self::set_user_version(conn, 4)?;
}
4 => {
log::info!("迁移数据库从 v4 到 v5(计费模式支持)");
Self::migrate_v4_to_v5(conn)?;
Self::set_user_version(conn, 5)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -529,7 +521,6 @@ impl Database {
// proxy_request_logs 表
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
request_model TEXT,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
@@ -686,8 +677,6 @@ impl Database {
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -890,45 +879,12 @@ impl Database {
Ok(())
}
/// v4 -> v5 迁移:新增计费模式配置与请求模型字段
fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "proxy_config")? {
Self::add_column_if_missing(
conn,
"proxy_config",
"default_cost_multiplier",
"TEXT NOT NULL DEFAULT '1'",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"pricing_model_source",
"TEXT NOT NULL DEFAULT 'response'",
)?;
}
if Self::table_exists(conn, "proxy_request_logs")? {
Self::add_column_if_missing(conn, "proxy_request_logs", "request_model", "TEXT")?;
}
log::info!("v4 -> v5 迁移完成:已添加计费模式与请求模型字段");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [
// Claude 4.6 系列
(
"claude-opus-4-6-20260206",
"Claude Opus 4.6",
"5",
"25",
"0.50",
"6.25",
),
// Claude 4.5 系列
// Claude 4.5 系列 (Latest Models)
(
"claude-opus-4-5-20251101",
"Claude Opus 4.5",
@@ -1034,40 +990,6 @@ impl Database {
"0.175",
"0",
),
// GPT-5.3 Codex 系列
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
(
"gpt-5.3-codex-low",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-medium",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-high",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-xhigh",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
// GPT-5.1 系列
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
@@ -1255,7 +1177,7 @@ impl Database {
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
conn.execute(
"INSERT OR IGNORE INTO model_pricing (
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
@@ -1282,8 +1204,14 @@ impl Database {
}
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
// 每次启动都执行 INSERT OR IGNORE,增量追加新模型,已有数据不覆盖
Self::seed_model_pricing(conn)
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
if count == 0 {
Self::seed_model_pricing(conn)?;
}
Ok(())
}
// --- 辅助方法 ---
+7 -67
View File
@@ -151,7 +151,7 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
}
#[test]
fn schema_migration_sets_user_version_when_missing() {
fn migration_sets_user_version_when_missing() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
@@ -169,7 +169,7 @@ fn schema_migration_sets_user_version_when_missing() {
}
#[test]
fn schema_migration_rejects_future_version() {
fn migration_rejects_future_version() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
@@ -183,7 +183,7 @@ fn schema_migration_rejects_future_version() {
}
#[test]
fn schema_migration_adds_missing_columns_for_providers() {
fn migration_adds_missing_columns_for_providers() {
let conn = Connection::open_in_memory().expect("open memory db");
// 创建旧版 providers 表,缺少新增列
@@ -224,7 +224,7 @@ fn schema_migration_adds_missing_columns_for_providers() {
}
#[test]
fn schema_migration_aligns_column_defaults_and_types() {
fn migration_aligns_column_defaults_and_types() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema");
@@ -268,67 +268,7 @@ fn schema_migration_aligns_column_defaults_and_types() {
}
#[test]
fn schema_create_tables_include_pricing_model_columns() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
assert_eq!(multiplier.r#type, "TEXT");
assert_eq!(multiplier.notnull, 1);
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
assert_eq!(pricing_source.r#type, "TEXT");
assert_eq!(pricing_source.notnull, 1);
assert_eq!(
normalize_default(&pricing_source.default).as_deref(),
Some("response")
);
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
assert_eq!(request_model.r#type, "TEXT");
assert_eq!(request_model.notnull, 0);
}
#[test]
fn schema_migration_v4_adds_pricing_model_columns() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(
r#"
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
"#,
)
.expect("seed v4 schema");
Database::set_user_version(&conn, 4).expect("set user_version=4");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
assert_eq!(multiplier.r#type, "TEXT");
assert_eq!(multiplier.notnull, 1);
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
assert_eq!(pricing_source.r#type, "TEXT");
assert_eq!(pricing_source.notnull, 1);
assert_eq!(
normalize_default(&pricing_source.default).as_deref(),
Some("response")
);
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
assert_eq!(request_model.r#type, "TEXT");
assert_eq!(request_model.notnull, 0);
assert_eq!(
Database::get_user_version(&conn).expect("version after migration"),
SCHEMA_VERSION
);
}
#[test]
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let conn = Connection::open_in_memory().expect("open memory db");
// 模拟测试版 v2user_version=2,但 proxy_config 仍是单例结构(无 app_type
@@ -493,7 +433,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
}
#[test]
fn schema_dry_run_does_not_write_to_disk() {
fn dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
@@ -567,7 +507,7 @@ fn dry_run_validates_schema_compatibility() {
}
#[test]
fn schema_model_pricing_is_seeded_on_init() {
fn model_pricing_is_seeded_on_init() {
let db = Database::memory().expect("create memory db");
let conn = db.conn.lock().expect("lock conn");
-2
View File
@@ -52,8 +52,6 @@ pub enum AppError {
},
#[error("数据库错误: {0}")]
Database(String),
#[error("OMO 配置文件不存在")]
OmoConfigNotFound,
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
+9 -1
View File
@@ -1,10 +1,18 @@
use crate::config::{get_home_dir, write_text_file};
use crate::config::write_text_file;
use crate::error::AppError;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
+9
View File
@@ -1,3 +1,4 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
@@ -6,6 +7,14 @@ use crate::config::atomic_write;
use crate::error::AppError;
use crate::gemini_config::get_gemini_settings_path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
pub user_config_path: String,
pub user_config_exists: bool,
pub server_count: usize,
}
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json
fn user_config_path() -> PathBuf {
get_gemini_settings_path()
-57
View File
@@ -21,7 +21,6 @@ mod provider;
mod provider_defaults;
mod proxy;
mod services;
mod session_manager;
mod settings;
mod store;
mod tray;
@@ -503,28 +502,6 @@ pub fn run() {
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
}
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
{
let has_omo = app_state
.db
.get_all_providers("opencode")
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
.unwrap_or(false);
if !has_omo {
match crate::services::OmoService::import_from_local(&app_state) {
Ok(provider) => {
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
}
Err(AppError::OmoConfigNotFound) => {
log::debug!("○ No OMO config to import");
}
Err(e) => {
log::warn!("✗ Failed to import OMO config from local: {e}");
}
}
}
}
// 3. 导入 MCP 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations...");
@@ -768,24 +745,6 @@ pub fn run() {
restore_proxy_state_on_startup(&state).await;
});
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
if settings.silent_startup {
// 静默启动模式:保持窗口隐藏
let _ = window.hide();
#[cfg(target_os = "windows")]
let _ = window.set_skip_taskbar(true);
#[cfg(target_os = "macos")]
tray::apply_tray_policy(app.handle(), false);
log::info!("静默启动模式:主窗口已隐藏");
} else {
// 正常启动模式:显示窗口
let _ = window.show();
log::info!("正常启动模式:主窗口已显示");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -873,7 +832,6 @@ pub fn run() {
commands::import_config_from_file,
commands::save_file_dialog,
commands::open_file_dialog,
commands::open_zip_file_dialog,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
@@ -903,7 +861,6 @@ pub fn run() {
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
commands::install_skills_from_zip,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
@@ -920,10 +877,6 @@ pub fn run() {
commands::update_global_proxy_config,
commands::get_proxy_config_for_app,
commands::update_proxy_config_for_app,
commands::get_default_cost_multiplier,
commands::set_default_cost_multiplier,
commands::get_pricing_model_source,
commands::set_pricing_model_source,
commands::is_proxy_running,
commands::is_live_takeover_active,
commands::switch_proxy_provider,
@@ -956,10 +909,6 @@ pub fn run() {
commands::stream_check_all_providers,
commands::get_stream_check_config,
commands::save_stream_check_config,
// Session manager
commands::list_sessions,
commands::get_session_messages,
commands::launch_session_terminal,
commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
@@ -978,12 +927,6 @@ pub fn run() {
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
// Window theme control
commands::set_window_theme,
commands::read_omo_local_file,
commands::get_current_omo_provider_id,
commands::get_omo_provider_count,
commands::disable_current_omo,
]);
let app = builder
+71 -54
View File
@@ -1,3 +1,26 @@
//! OpenCode 配置文件读写模块
//!
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
//!
//! ## 配置文件格式
//!
//! ```json
//! {
//! "$schema": "https://opencode.ai/config.json",
//! "provider": {
//! "my-provider": {
//! "npm": "@ai-sdk/openai-compatible",
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
//! }
//! },
//! "mcp": {
//! "my-server": { "type": "local", "command": ["..."] }
//! }
//! }
//! ```
use crate::config::write_json_file;
use crate::error::AppError;
use crate::provider::OpenCodeProviderConfig;
@@ -6,29 +29,52 @@ use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 OpenCode 配置目录
///
/// 默认路径: `~/.config/opencode/`
/// 可通过 settings.opencode_config_dir 覆盖
pub fn get_opencode_dir() -> PathBuf {
if let Some(override_dir) = get_opencode_override_dir() {
return override_dir;
}
// 所有平台统一使用 ~/.config/opencode
dirs::home_dir()
.map(|h| h.join(".config").join("opencode"))
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
}
/// 获取 OpenCode 配置文件路径
///
/// 返回 `~/.config/opencode/opencode.json`
pub fn get_opencode_config_path() -> PathBuf {
get_opencode_dir().join("opencode.json")
}
/// 获取 OpenCode 环境变量文件路径(如果存在)
///
/// 返回 `~/.config/opencode/.env`
#[allow(dead_code)]
pub fn get_opencode_env_path() -> PathBuf {
get_opencode_dir().join(".env")
}
// ============================================================================
// Core Read/Write Functions
// ============================================================================
/// 读取 OpenCode 配置文件
///
/// 返回完整的配置 JSON 对象
pub fn read_opencode_config() -> Result<Value, AppError> {
let path = get_opencode_config_path();
if !path.exists() {
// Return empty config with schema
return Ok(json!({
"$schema": "https://opencode.ai/config.json"
}));
@@ -38,14 +84,23 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
}
/// 写入 OpenCode 配置文件(原子写入)
///
/// 使用临时文件 + 重命名确保原子性
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
let path = get_opencode_config_path();
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
write_json_file(&path, config)?;
log::debug!("OpenCode config written to {path:?}");
Ok(())
}
// ============================================================================
// Provider Functions (Untyped - for raw JSON operations)
// ============================================================================
/// 获取所有供应商配置(原始 JSON)
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -55,6 +110,7 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
.unwrap_or_default())
}
/// 设置供应商配置(原始 JSON)
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
@@ -72,6 +128,7 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
write_opencode_config(&full_config)
}
/// 删除供应商配置
pub fn remove_provider(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
@@ -82,6 +139,11 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
write_opencode_config(&config)
}
// ============================================================================
// Provider Functions (Typed - using OpenCodeProviderConfig)
// ============================================================================
/// 获取所有供应商配置(类型化)
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
@@ -93,6 +155,7 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
}
Err(e) => {
log::warn!("Failed to parse provider '{id}': {e}");
// Skip invalid providers but continue
}
}
}
@@ -100,11 +163,17 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
Ok(result)
}
/// 设置供应商配置(类型化)
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
set_provider(id, value)
}
// ============================================================================
// MCP Functions
// ============================================================================
/// 获取所有 MCP 服务器配置
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -114,6 +183,7 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
.unwrap_or_default())
}
/// 设置 MCP 服务器配置
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
@@ -128,6 +198,7 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
write_opencode_config(&full_config)
}
/// 删除 MCP 服务器配置
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
@@ -137,57 +208,3 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
write_opencode_config(&config)
}
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
match plugins {
Some(arr) => {
if plugin_name.starts_with("oh-my-opencode")
&& !plugin_name.starts_with("oh-my-opencode-slim")
{
arr.retain(|v| {
v.as_str()
.map(|s| !s.starts_with("oh-my-opencode-slim"))
.unwrap_or(true)
});
}
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
if !already_exists {
arr.push(Value::String(plugin_name.to_string()));
}
}
None => {
config["plugin"] = json!([plugin_name]);
}
}
write_opencode_config(&config)
}
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
arr.retain(|v| {
v.as_str()
.map(|s| {
if !s.starts_with(prefix) {
return true; // Keep: doesn't match prefix at all
}
let rest = &s[prefix.len()..];
rest.starts_with('-')
})
.unwrap_or(true)
});
if arr.is_empty() {
config.as_object_mut().map(|obj| obj.remove("plugin"));
}
}
write_opencode_config(&config)
}
+24 -326
View File
@@ -191,6 +191,23 @@ pub struct ProviderProxyConfig {
pub proxy_password: Option<String>,
}
/// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FormatTransformConfig {
/// 是否启用格式转换
#[serde(default)]
pub enabled: bool,
/// 源格式:anthropic, openai, gemini
#[serde(rename = "sourceFormat", skip_serializing_if = "Option::is_none")]
pub source_format: Option<String>,
/// 目标格式:anthropic, openai, gemini
#[serde(rename = "targetFormat", skip_serializing_if = "Option::is_none")]
pub target_format: Option<String>,
/// 是否转换流式响应(默认 true)
#[serde(rename = "transformStreaming", skip_serializing_if = "Option::is_none")]
pub transform_streaming: Option<bool>,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
@@ -215,9 +232,6 @@ pub struct ProviderMeta {
/// 成本倍数(用于计算实际成本)
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
pub cost_multiplier: Option<String>,
/// 计费模式来源(response/request
#[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
pub pricing_model_source: Option<String>,
/// 每日消费限额(USD
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
pub limit_daily_usd: Option<String>,
@@ -230,11 +244,9 @@ pub struct ProviderMeta {
/// 供应商单独的代理配置
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>,
/// Claude API 格式(仅 Claude 供应商使用
/// - "anthropic": 原生 Anthropic Messages API,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
#[serde(rename = "formatTransform", skip_serializing_if = "Option::is_none")]
pub format_transform: Option<FormatTransformConfig>,
}
impl ProviderManager {
@@ -446,18 +458,11 @@ impl UniversalProvider {
.and_then(|m| m.reasoning_effort.clone())
.unwrap_or_else(|| "high".to_string());
// Codex/OpenAI 的 base_url 既可能是纯 origin(需要补 /v1),也可能包含自定义前缀(不应强行补版本
let base_trimmed = self.base_url.trim_end_matches('/');
let origin_only = match base_trimmed.split_once("://") {
Some((_scheme, rest)) => !rest.contains('/'),
None => !base_trimmed.contains('/'),
};
let codex_base_url = if base_trimmed.ends_with("/v1") {
base_trimmed.to_string()
} else if origin_only {
format!("{base_trimmed}/v1")
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API
let codex_base_url = if self.base_url.ends_with("/v1") {
self.base_url.clone()
} else {
base_trimmed.to_string()
format!("{}/v1", self.base_url.trim_end_matches('/'))
};
// 生成 Codex 的 config.toml 内容
@@ -629,310 +634,3 @@ pub struct OpenCodeModelLimit {
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::{
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
ProviderManager, ProviderMeta, UniversalProvider,
};
use serde_json::json;
#[test]
fn provider_meta_serializes_pricing_model_source() {
let mut meta = ProviderMeta::default();
meta.pricing_model_source = Some("response".to_string());
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
assert_eq!(
value
.get("pricingModelSource")
.and_then(|item| item.as_str()),
Some("response")
);
assert!(value.get("pricing_model_source").is_none());
}
#[test]
fn provider_meta_omits_pricing_model_source_when_none() {
let meta = ProviderMeta::default();
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
assert!(value.get("pricingModelSource").is_none());
}
#[test]
fn provider_with_id_populates_defaults() {
let settings_config = json!({
"env": { "API_KEY": "test" }
});
let provider = Provider::with_id(
"provider-1".to_string(),
"Provider".to_string(),
settings_config.clone(),
Some("https://example.com".to_string()),
);
assert_eq!(provider.id, "provider-1");
assert_eq!(provider.name, "Provider");
assert_eq!(provider.settings_config, settings_config);
assert_eq!(provider.website_url.as_deref(), Some("https://example.com"));
assert!(provider.category.is_none());
assert!(provider.created_at.is_none());
assert!(provider.sort_index.is_none());
assert!(provider.notes.is_none());
assert!(provider.meta.is_none());
assert!(provider.icon.is_none());
assert!(provider.icon_color.is_none());
assert!(!provider.in_failover_queue);
}
#[test]
fn provider_manager_get_all_providers_returns_map() {
let mut manager = ProviderManager::default();
let provider = Provider::with_id(
"provider-1".to_string(),
"Provider".to_string(),
json!({ "env": {} }),
None,
);
manager.providers.insert("provider-1".to_string(), provider);
assert_eq!(manager.get_all_providers().len(), 1);
assert!(manager.get_all_providers().contains_key("provider-1"));
}
#[test]
fn universal_provider_to_claude_provider_uses_models() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
universal.apps.claude = true;
universal.models.claude = Some(ClaudeModelConfig {
model: Some("claude-main".to_string()),
haiku_model: Some("claude-haiku".to_string()),
sonnet_model: Some("claude-sonnet".to_string()),
opus_model: Some("claude-opus".to_string()),
});
let provider = universal.to_claude_provider().expect("claude provider");
assert_eq!(provider.id, "universal-claude-u1");
assert_eq!(provider.name, "Universal");
assert_eq!(provider.category.as_deref(), Some("aggregator"));
assert_eq!(
provider
.settings_config
.pointer("/env/ANTHROPIC_MODEL")
.and_then(|item| item.as_str()),
Some("claude-main")
);
assert_eq!(
provider
.settings_config
.pointer("/env/ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|item| item.as_str()),
Some("claude-haiku")
);
assert_eq!(
provider
.settings_config
.pointer("/env/ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|item| item.as_str()),
Some("claude-sonnet")
);
assert_eq!(
provider
.settings_config
.pointer("/env/ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|item| item.as_str()),
Some("claude-opus")
);
}
#[test]
fn universal_provider_to_claude_provider_disabled_returns_none() {
let universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
assert!(universal.to_claude_provider().is_none());
}
#[test]
fn universal_provider_to_codex_provider_appends_v1() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
universal.apps.codex = true;
universal.models.codex = Some(CodexModelConfig {
model: Some("gpt-4o-mini".to_string()),
reasoning_effort: Some("low".to_string()),
});
let provider = universal.to_codex_provider().expect("codex provider");
let config = provider
.settings_config
.get("config")
.and_then(|item| item.as_str())
.expect("config toml");
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
assert_eq!(
provider
.settings_config
.pointer("/auth/OPENAI_API_KEY")
.and_then(|item| item.as_str()),
Some("api-key")
);
}
#[test]
fn universal_provider_to_codex_provider_keeps_v1_suffix() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com/v1".to_string(),
"api-key".to_string(),
);
universal.apps.codex = true;
let provider = universal.to_codex_provider().expect("codex provider");
let config = provider
.settings_config
.get("config")
.and_then(|item| item.as_str())
.expect("config toml");
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
}
#[test]
fn universal_provider_to_codex_provider_disabled_returns_none() {
let universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
assert!(universal.to_codex_provider().is_none());
}
#[test]
fn universal_provider_to_gemini_provider_defaults_model() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
universal.apps.gemini = true;
let provider = universal.to_gemini_provider().expect("gemini provider");
assert_eq!(
provider
.settings_config
.pointer("/env/GEMINI_MODEL")
.and_then(|item| item.as_str()),
Some("gemini-2.5-pro")
);
}
#[test]
fn universal_provider_to_gemini_provider_uses_model() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
universal.apps.gemini = true;
universal.models.gemini = Some(GeminiModelConfig {
model: Some("gemini-custom".to_string()),
});
let provider = universal.to_gemini_provider().expect("gemini provider");
assert_eq!(
provider
.settings_config
.pointer("/env/GEMINI_MODEL")
.and_then(|item| item.as_str()),
Some("gemini-custom")
);
}
#[test]
fn opencode_provider_config_defaults() {
let config = OpenCodeProviderConfig::default();
assert_eq!(config.npm, "@ai-sdk/openai-compatible");
assert!(config.name.is_none());
assert!(config.models.is_empty());
assert!(config.options.base_url.is_none());
assert!(config.options.api_key.is_none());
assert!(config.options.headers.is_none());
assert!(config.options.extra.is_empty());
}
#[test]
fn universal_codex_provider_origin_base_url_adds_v1() {
let mut p = UniversalProvider::new(
"id".to_string(),
"Test".to_string(),
"custom".to_string(),
"https://api.openai.com".to_string(),
"sk-test".to_string(),
);
p.apps.codex = true;
let provider = p.to_codex_provider().expect("should build codex provider");
let toml = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("config should be a toml string");
assert!(toml.contains("base_url = \"https://api.openai.com/v1\""));
}
#[test]
fn universal_codex_provider_custom_prefix_does_not_force_v1() {
let mut p = UniversalProvider::new(
"id".to_string(),
"Test".to_string(),
"custom".to_string(),
"https://example.com/openai".to_string(),
"sk-test".to_string(),
);
p.apps.codex = true;
let provider = p.to_codex_provider().expect("should build codex provider");
let toml = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("config should be a toml string");
assert!(toml.contains("base_url = \"https://example.com/openai\""));
assert!(!toml.contains("https://example.com/openai/v1"));
}
}
+51 -17
View File
@@ -9,6 +9,7 @@ use super::{
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
transform::{get_transformer, TransformConfig},
types::{ProxyStatus, RectifierConfig},
ProxyError,
};
@@ -558,26 +559,63 @@ impl RequestForwarder {
// 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?;
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
// 获取格式转换配置
let transform_config = TransformConfig::from_provider(provider);
let needs_transform = transform_config.needs_transform();
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
"/v1/chat/completions"
} else {
endpoint
};
// 如果需要转换但找不到转换器,直接返回错误(避免静默透传后在响应阶段失败)
let transformer = if needs_transform {
let t = get_transformer(
transform_config.source_format,
transform_config.target_format,
);
if t.is_none() {
log::error!(
"[Forwarder] 格式转换已启用但找不到转换器: {:?} → {:?}",
transform_config.source_format,
transform_config.target_format
);
return Err(ProxyError::TransformError(format!(
"No transformer registered for {:?} → {:?}. Please disable format transform or use supported formats (Anthropic ↔ OpenAI).",
transform_config.source_format,
transform_config.target_format
)));
}
t
} else {
None
};
// 确定有效端点
let effective_endpoint = if let Some(ref t) = transformer {
t.transform_endpoint(endpoint)
} else {
endpoint.to_string()
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
let url = adapter.build_url(&base_url, &effective_endpoint);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
let (mut mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 如果启用格式转换但禁用流式转换,强制将 stream 设为 false
// 避免上游返回 SSE 流但我们无法转换的情况
if needs_transform && !transform_config.transform_streaming {
if let Some(stream_val) = mapped_body.get("stream") {
if stream_val.as_bool() == Some(true) {
log::info!("[Forwarder] transform_streaming=false,强制将 stream 设为 false");
if let Some(obj) = mapped_body.as_object_mut() {
obj.insert("stream".to_string(), serde_json::Value::Bool(false));
}
}
}
}
// 转换请求体(如果需要)
let request_body = if needs_transform {
adapter.transform_request(mapped_body, provider)?
let request_body = if let Some(ref t) = transformer {
t.transform_request(mapped_body)?
} else {
mapped_body
};
@@ -666,11 +704,7 @@ impl RequestForwarder {
// 输出请求信息日志
let tag = adapter.name();
let request_model = filtered_body
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
log::debug!("[{tag}] >>> 请求 URL: {url}");
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",
+57 -26
View File
@@ -13,16 +13,18 @@ use super::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
},
handler_context::RequestContext,
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
server::ProxyState,
transform::{get_transformer, TransformConfig},
types::*,
usage::parser::TokenUsage,
ProxyError,
};
use crate::app_config::AppType;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use rust_decimal::Decimal;
use serde_json::{json, Value};
use std::str::FromStr;
// ============================================================================
// 健康检查和状态查询(简单端点)
@@ -92,13 +94,20 @@ pub async fn handle_messages(
ctx.provider = result.provider;
let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
// 检查是否需要格式转换(通过 Provider 配置
let transform_config = TransformConfig::from_provider(&ctx.provider);
// Claude 特有:格式转换处理
if needs_transform {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
if transform_config.needs_transform() {
return handle_claude_transform(
response,
&ctx,
&state,
&body,
is_stream,
&transform_config,
)
.await;
}
// 通用响应处理(透传模式)
@@ -114,13 +123,26 @@ async fn handle_claude_transform(
state: &ProxyState,
_original_body: &Value,
is_stream: bool,
transform_config: &TransformConfig,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
if is_stream {
// 获取响应转换器(OpenAI → Anthropic
let response_transformer = get_transformer(
transform_config.target_format,
transform_config.source_format,
)
.ok_or_else(|| {
ProxyError::TransformError(format!(
"No transformer for {:?} → {:?}",
transform_config.target_format, transform_config.source_format
))
})?;
if is_stream && transform_config.transform_streaming {
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
let stream = response.bytes_stream();
let sse_stream = create_anthropic_sse_stream(stream);
let sse_stream = response_transformer.transform_stream(Box::pin(stream));
// 创建使用量收集器
let usage_collector = {
@@ -143,7 +165,6 @@ async fn handle_claude_transform(
&provider_id,
"claude",
&model,
&model,
usage,
latency_ms,
first_token_ms,
@@ -201,10 +222,12 @@ async fn handle_claude_transform(
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
})?;
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}");
e
})?;
let anthropic_response = response_transformer
.transform_response(openai_response)
.map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}");
e
})?;
// 记录使用量
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
@@ -214,7 +237,6 @@ async fn handle_claude_transform(
.unwrap_or("unknown");
let latency_ms = ctx.latency_ms();
let request_model = ctx.request_model.clone();
tokio::spawn({
let state = state.clone();
let provider_id = ctx.provider.id.clone();
@@ -225,7 +247,6 @@ async fn handle_claude_transform(
&provider_id,
"claude",
&model,
&request_model,
usage,
latency_ms,
None,
@@ -284,7 +305,7 @@ pub async fn handle_chat_completions(
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/chat/completions",
"/v1/chat/completions",
body,
headers,
ctx.get_providers(),
@@ -325,7 +346,7 @@ pub async fn handle_responses(
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/responses",
"/v1/responses",
body,
headers,
ctx.get_providers(),
@@ -442,7 +463,6 @@ async fn log_usage(
provider_id: &str,
app_type: &str,
model: &str,
request_model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -453,12 +473,25 @@ async fn log_usage(
let logger = UsageLogger::new(&state.db);
let (multiplier, pricing_model_source) =
logger.resolve_pricing_config(provider_id, app_type).await;
let pricing_model = if pricing_model_source == "request" {
request_model
} else {
model
// 获取 provider 的 cost_multiplier
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else {
Decimal::from(1)
}
} else {
Decimal::from(1)
}
}
_ => Decimal::from(1),
};
let request_id = uuid::Uuid::new_v4().to_string();
@@ -468,8 +501,6 @@ async fn log_usage(
provider_id.to_string(),
app_type.to_string(),
model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
usage,
multiplier,
latency_ms,
+6 -61
View File
@@ -17,33 +17,6 @@ static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
/// 当前代理 URL(用于日志和状态查询)
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
/// CC Switch 代理服务器当前监听的端口
static CC_SWITCH_PROXY_PORT: OnceCell<RwLock<u16>> = OnceCell::new();
/// 设置 CC Switch 代理服务器的监听端口
///
/// 应在代理服务器启动时调用,以便系统代理检测能正确识别自己的端口
pub fn set_proxy_port(port: u16) {
if let Some(lock) = CC_SWITCH_PROXY_PORT.get() {
if let Ok(mut current_port) = lock.write() {
*current_port = port;
log::debug!("[GlobalProxy] Updated CC Switch proxy port to {port}");
}
} else {
let _ = CC_SWITCH_PROXY_PORT.set(RwLock::new(port));
log::debug!("[GlobalProxy] Initialized CC Switch proxy port to {port}");
}
}
/// 获取 CC Switch 代理服务器的监听端口
fn get_proxy_port() -> u16 {
CC_SWITCH_PROXY_PORT
.get()
.and_then(|lock| lock.read().ok())
.map(|port| *port)
.unwrap_or(15721) // 默认端口作为回退
}
/// 初始化全局 HTTP 客户端
///
/// 应在应用启动时调用一次。
@@ -285,17 +258,9 @@ fn proxy_points_to_loopback(value: &str) -> bool {
.unwrap_or(false)
}
// 检查是否指向 CC Switch 自己的代理端口
// 只有指向自己的代理才需要跳过,避免递归
fn is_cc_switch_proxy_port(port: Option<u16>) -> bool {
let cc_switch_port = get_proxy_port();
port == Some(cc_switch_port)
}
if let Ok(parsed) = url::Url::parse(value) {
if let Some(host) = parsed.host_str() {
// 只有当主机是 loopback 且端口是 CC Switch 的端口时才返回 true
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
return host_is_loopback(host);
}
return false;
}
@@ -303,7 +268,7 @@ fn proxy_points_to_loopback(value: &str) -> bool {
let with_scheme = format!("http://{value}");
if let Ok(parsed) = url::Url::parse(&with_scheme) {
if let Some(host) = parsed.host_str() {
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
return host_is_loopback(host);
}
}
@@ -483,30 +448,16 @@ mod tests {
#[test]
fn test_proxy_points_to_loopback() {
// 设置 CC Switch 代理端口为 15721(默认值)
set_proxy_port(15721);
// 只有指向 CC Switch 自己端口的 loopback 地址才返回 true
assert!(proxy_points_to_loopback("http://127.0.0.1:15721"));
assert!(proxy_points_to_loopback("socks5://localhost:15721"));
assert!(proxy_points_to_loopback("127.0.0.1:15721"));
// 其他 loopback 端口不应该被跳过(允许使用其他本地代理工具)
assert!(!proxy_points_to_loopback("http://127.0.0.1:7890"));
assert!(!proxy_points_to_loopback("socks5://localhost:1080"));
// 非 loopback 地址不应该被跳过
assert!(proxy_points_to_loopback("http://127.0.0.1:7890"));
assert!(proxy_points_to_loopback("socks5://localhost:1080"));
assert!(proxy_points_to_loopback("127.0.0.1:7890"));
assert!(!proxy_points_to_loopback("http://192.168.1.10:7890"));
assert!(!proxy_points_to_loopback("http://192.168.1.10:15721"));
}
#[test]
fn test_system_proxy_points_to_loopback() {
let _guard = env_lock().lock().unwrap();
// 设置 CC Switch 代理端口
set_proxy_port(15721);
let keys = [
"HTTP_PROXY",
"http_proxy",
@@ -520,15 +471,9 @@ mod tests {
std::env::remove_var(key);
}
// 指向 CC Switch 端口的代理应该被跳过
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:15721");
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
assert!(system_proxy_points_to_loopback());
// 指向其他端口的本地代理不应该被跳过
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
assert!(!system_proxy_points_to_loopback());
// 非 loopback 地址不应该被跳过
std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890");
assert!(!system_proxy_points_to_loopback());
+1
View File
@@ -22,6 +22,7 @@ pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub mod thinking_rectifier;
pub mod transform;
pub(crate) mod types;
pub mod usage;
-46
View File
@@ -6,7 +6,6 @@ use super::auth::AuthInfo;
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
use serde_json::Value;
/// 供应商适配器 Trait
///
@@ -83,49 +82,4 @@ pub trait ProviderAdapter: Send + Sync {
/// # Returns
/// 添加了认证头的 RequestBuilder
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
/// 是否需要格式转换
///
/// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
///
/// # Arguments
/// * `provider` - Provider 配置
fn needs_transform(&self, _provider: &Provider) -> bool {
false
}
/// 转换请求体
///
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
/// 默认实现直接返回原始请求体(透传)。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `provider` - Provider 配置(用于获取模型映射等)
///
/// # Returns
/// * `Ok(Value)` - 转换后的请求体
/// * `Err(ProxyError)` - 转换失败
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
Ok(body)
}
/// 转换响应体
///
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
/// 默认实现直接返回原始响应体(透传)。
///
/// # Arguments
/// * `body` - 原始响应体
///
/// # Returns
/// * `Ok(Value)` - 转换后的响应体
/// * `Err(ProxyError)` - 转换失败
///
/// Note: 响应转换将在 handler 层集成,目前预留接口
#[allow(dead_code)]
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
Ok(body)
}
}
+5 -208
View File
@@ -1,15 +1,11 @@
//! Claude (Anthropic) Provider Adapter
//!
//! 支持透传模式和 OpenAI Chat Completions 格式转换模式
//!
//! ## API 格式
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
//! 支持透传模式和 OpenRouter 兼容模式
//!
//! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
@@ -52,55 +48,6 @@ impl ClaudeAdapter {
false
}
/// 获取 API 格式
///
/// 从 provider.meta.api_format 读取格式设置:
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
fn get_api_format(&self, provider: &Provider) -> &'static str {
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
if let Some(meta) = provider.meta.as_ref() {
if let Some(api_format) = meta.api_format.as_deref() {
return if api_format == "openai_chat" {
"openai_chat"
} else {
"anthropic"
};
}
}
// 2) Backward compatibility: legacy settings_config.api_format
if let Some(api_format) = provider
.settings_config
.get("api_format")
.and_then(|v| v.as_str())
{
return if api_format == "openai_chat" {
"openai_chat"
} else {
"anthropic"
};
}
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
let raw = provider.settings_config.get("openrouter_compat_mode");
let enabled = match raw {
Some(serde_json::Value::Bool(v)) => *v,
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
Some(serde_json::Value::String(value)) => {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
_ => false,
};
if enabled {
"openai_chat"
} else {
"anthropic"
}
}
/// 检测是否为仅 Bearer 认证模式
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
// 检查 settings_config 中的 auth_mode
@@ -252,23 +199,15 @@ impl ProviderAdapter for ClaudeAdapter {
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
let mut base = format!(
let base = format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
);
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
while base.contains("/v1/v1") {
base = base.replace("/v1/v1", "/v1");
}
// 为 Claude 相关端点添加 ?beta=true 参数
// 为 /v1/messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
// 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions"))
&& !endpoint.contains('?')
{
if endpoint.contains("/v1/messages") && !endpoint.contains("?") {
format!("{base}?beta=true")
} else {
base
@@ -294,31 +233,11 @@ impl ProviderAdapter for ClaudeAdapter {
_ => request,
}
}
fn needs_transform(&self, provider: &Provider) -> bool {
// 根据 api_format 配置决定是否需要格式转换
// - "anthropic" (默认): 直接透传,无需转换
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
self.get_api_format(provider) == "openai_chat"
}
fn transform_request(
&self,
body: serde_json::Value,
_provider: &Provider,
) -> Result<serde_json::Value, ProxyError> {
super::transform::anthropic_to_openai(body)
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
super::transform::openai_to_anthropic(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ProviderMeta;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
@@ -338,23 +257,6 @@ mod tests {
}
}
fn create_provider_with_meta(config: serde_json::Value, meta: ProviderMeta) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Claude".to_string(),
settings_config: config,
website_url: None,
category: Some("claude".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: Some(meta),
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_extract_base_url_from_env() {
let adapter = ClaudeAdapter::new();
@@ -512,109 +414,4 @@ mod tests {
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
}
#[test]
fn test_needs_transform() {
let adapter = ClaudeAdapter::new();
// Default: no transform (anthropic format) - no meta
let anthropic_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
assert!(!adapter.needs_transform(&anthropic_provider));
// Explicit anthropic format in meta: no transform
let explicit_anthropic = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
}
}),
ProviderMeta {
api_format: Some("anthropic".to_string()),
..Default::default()
},
);
assert!(!adapter.needs_transform(&explicit_anthropic));
// Legacy settings_config.api_format: openai_chat should enable transform
let legacy_settings_api_format = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"api_format": "openai_chat"
}));
assert!(adapter.needs_transform(&legacy_settings_api_format));
// Legacy openrouter_compat_mode: bool/number/string should enable transform
let legacy_openrouter_bool = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"openrouter_compat_mode": true
}));
assert!(adapter.needs_transform(&legacy_openrouter_bool));
let legacy_openrouter_num = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"openrouter_compat_mode": 1
}));
assert!(adapter.needs_transform(&legacy_openrouter_num));
let legacy_openrouter_str = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"openrouter_compat_mode": "true"
}));
assert!(adapter.needs_transform(&legacy_openrouter_str));
// OpenAI Chat format in meta: needs transform
let openai_chat_provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
}
}),
ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
},
);
assert!(adapter.needs_transform(&openai_chat_provider));
// meta takes precedence over legacy settings_config fields
let meta_precedence_over_settings = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"api_format": "openai_chat",
"openrouter_compat_mode": true
}),
ProviderMeta {
api_format: Some("anthropic".to_string()),
..Default::default()
},
);
assert!(!adapter.needs_transform(&meta_precedence_over_settings));
// Unknown format in meta: default to anthropic (no transform)
let unknown_format = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
}
}),
ProviderMeta {
api_format: Some("unknown".to_string()),
..Default::default()
},
);
assert!(!adapter.needs_transform(&unknown_format));
}
}
+42 -38
View File
@@ -141,33 +141,24 @@ impl ProviderAdapter for CodexAdapter {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// OpenAI/Codex 的 base_url 可能是:
// - 纯 origin: https://api.openai.com (需要自动补 /v1)
// - 已含 /v1: https://api.openai.com/v1 (直接拼接)
// - 自定义前缀: https://xxx/openai (不添加 /v1,直接拼接)
// 检查 base_url 是否已包含 endpoint 的核心路径
// 例如:base_url = "https://api.example.com/v1/chat/completions"
// endpoint = "/v1/chat/completions"
// 此时不应再拼接,直接返回 base_url
let endpoint_core = endpoint_trimmed
.trim_start_matches("v1/")
.trim_start_matches("v1");
let endpoint_core = endpoint_core.trim_start_matches('/');
// 检查 base_url 是否已经包含 /v1
let already_has_v1 = base_trimmed.ends_with("/v1");
// 如果 base_url 已经以 endpoint 核心路径结尾,直接返回 base_url
if !endpoint_core.is_empty() && base_trimmed.ends_with(endpoint_core) {
return base_trimmed.to_string();
}
// 检查是否是纯 origin(没有路径部分)
let origin_only = match base_trimmed.split_once("://") {
Some((_scheme, rest)) => !rest.contains('/'),
None => !base_trimmed.contains('/'),
};
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
let mut url = if already_has_v1 {
// 已经有 /v1,直接拼接
format!("{base_trimmed}/{endpoint_trimmed}")
} else if origin_only {
// 纯 origin,添加 /v1
format!("{base_trimmed}/v1/{endpoint_trimmed}")
} else {
// 自定义前缀,不添加 /v1,直接拼接
format!("{base_trimmed}/{endpoint_trimmed}")
};
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
while url.contains("/v1/v1") {
// 去除重复的 /v1/v1
if url.contains("/v1/v1") {
url = url.replace("/v1/v1", "/v1");
}
@@ -246,20 +237,6 @@ mod tests {
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_build_url_origin_adds_v1() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://api.openai.com", "/responses");
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_build_url_custom_prefix_no_v1() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://example.com/openai", "/responses");
assert_eq!(url, "https://example.com/openai/responses");
}
#[test]
fn test_build_url_dedup_v1() {
let adapter = CodexAdapter::new();
@@ -268,6 +245,33 @@ mod tests {
assert_eq!(url, "https://www.packyapi.com/v1/responses");
}
#[test]
fn test_build_url_base_already_has_chat_completions() {
let adapter = CodexAdapter::new();
// base_url 已包含 chat/completions,不应再拼接
let url = adapter.build_url(
"https://api.example.com/v1/chat/completions",
"/v1/chat/completions",
);
assert_eq!(url, "https://api.example.com/v1/chat/completions");
}
#[test]
fn test_build_url_base_already_has_responses() {
let adapter = CodexAdapter::new();
// base_url 已包含 responses,不应再拼接
let url = adapter.build_url("https://api.example.com/v1/responses", "/v1/responses");
assert_eq!(url, "https://api.example.com/v1/responses");
}
#[test]
fn test_build_url_base_without_endpoint() {
let adapter = CodexAdapter::new();
// base_url 不包含 endpoint,应正常拼接
let url = adapter.build_url("https://api.example.com/v1", "/v1/chat/completions");
assert_eq!(url, "https://api.example.com/v1/chat/completions");
}
// 官方客户端检测测试
#[test]
fn test_is_official_client_vscode() {
-2
View File
@@ -17,8 +17,6 @@ mod claude;
mod codex;
mod gemini;
pub mod models;
pub mod streaming;
pub mod transform;
use crate::app_config::AppType;
use crate::provider::Provider;
@@ -2,8 +2,6 @@
//!
//! 用于 Anthropic Messages API 的请求/响应格式转换
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -2,8 +2,6 @@
//!
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use serde_json::Value;
+23 -209
View File
@@ -13,8 +13,10 @@ use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use reqwest::header::HeaderMap;
use rust_decimal::Decimal;
use serde_json::Value;
use std::{
str::FromStr,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
@@ -126,15 +128,7 @@ pub async fn handle_non_streaming(
ctx.request_model.clone()
};
spawn_log_usage(
state,
ctx,
usage,
&model,
&ctx.request_model,
status.as_u16(),
false,
);
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
} else {
let model = json_value
.get("model")
@@ -146,7 +140,6 @@ pub async fn handle_non_streaming(
ctx,
TokenUsage::default(),
&model,
&ctx.request_model,
status.as_u16(),
false,
);
@@ -166,7 +159,6 @@ pub async fn handle_non_streaming(
ctx,
TokenUsage::default(),
&ctx.request_model,
&ctx.request_model,
status.as_u16(),
false,
);
@@ -301,7 +293,6 @@ fn create_usage_collector(
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
tokio::spawn(async move {
log_usage_internal(
@@ -309,7 +300,6 @@ fn create_usage_collector(
&provider_id,
app_type_str,
&model,
&request_model,
usage,
latency_ms,
first_token_ms,
@@ -325,7 +315,6 @@ fn create_usage_collector(
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
tokio::spawn(async move {
log_usage_internal(
@@ -333,7 +322,6 @@ fn create_usage_collector(
&provider_id,
app_type_str,
&model,
&request_model,
TokenUsage::default(),
latency_ms,
first_token_ms,
@@ -354,7 +342,6 @@ fn spawn_log_usage(
ctx: &RequestContext,
usage: TokenUsage,
model: &str,
request_model: &str,
status_code: u16,
is_streaming: bool,
) {
@@ -362,7 +349,6 @@ fn spawn_log_usage(
let provider_id = ctx.provider.id.clone();
let app_type_str = ctx.app_type_str.to_string();
let model = model.to_string();
let request_model = request_model.to_string();
let latency_ms = ctx.latency_ms();
let session_id = ctx.session_id.clone();
@@ -372,7 +358,6 @@ fn spawn_log_usage(
&provider_id,
&app_type_str,
&model,
&request_model,
usage,
latency_ms,
None,
@@ -391,7 +376,6 @@ async fn log_usage_internal(
provider_id: &str,
app_type: &str,
model: &str,
request_model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -402,12 +386,26 @@ async fn log_usage_internal(
use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db);
let (multiplier, pricing_model_source) =
logger.resolve_pricing_config(provider_id, app_type).await;
let pricing_model = if pricing_model_source == "request" {
request_model
} else {
model
// 获取 provider 的 cost_multiplier
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else {
Decimal::from(1)
}
} else {
Decimal::from(1)
}
}
_ => Decimal::from(1),
};
let request_id = uuid::Uuid::new_v4().to_string();
@@ -426,8 +424,6 @@ async fn log_usage_internal(
provider_id.to_string(),
app_type.to_string(),
model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
usage,
multiplier,
latency_ms,
@@ -560,185 +556,3 @@ fn format_headers(headers: &HeaderMap) -> String {
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use crate::error::AppError;
use crate::provider::ProviderMeta;
use crate::proxy::failover_switch::FailoverSwitchManager;
use crate::proxy::provider_router::ProviderRouter;
use crate::proxy::types::{ProxyConfig, ProxyStatus};
use rust_decimal::Decimal;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
fn build_state(db: Arc<Database>) -> ProxyState {
ProxyState {
db: db.clone(),
config: Arc::new(RwLock::new(ProxyConfig::default())),
status: Arc::new(RwLock::new(ProxyStatus::default())),
start_time: Arc::new(RwLock::new(None)),
current_providers: Arc::new(RwLock::new(HashMap::new())),
provider_router: Arc::new(ProviderRouter::new(db.clone())),
app_handle: None,
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
}
}
fn seed_pricing(db: &Database) -> Result<(), AppError> {
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["resp-model", "Resp Model", "1.0", "0"],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["req-model", "Req Model", "2.0", "0"],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
fn insert_provider(
db: &Database,
id: &str,
app_type: &str,
meta: ProviderMeta,
) -> Result<(), AppError> {
let meta_json =
serde_json::to_string(&meta).map_err(|e| AppError::Database(e.to_string()))?;
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config, meta)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![id, app_type, "Test Provider", "{}", meta_json],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
#[tokio::test]
async fn test_log_usage_uses_provider_override_config() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
let app_type = "claude";
db.set_default_cost_multiplier(app_type, "1.5").await?;
db.set_pricing_model_source(app_type, "response").await?;
seed_pricing(&db)?;
let mut meta = ProviderMeta::default();
meta.cost_multiplier = Some("2".to_string());
meta.pricing_model_source = Some("request".to_string());
insert_provider(&db, "provider-1", app_type, meta)?;
let state = build_state(db.clone());
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
};
log_usage_internal(
&state,
"provider-1",
app_type,
"resp-model",
"req-model",
usage,
10,
None,
false,
200,
None,
)
.await;
let conn = crate::database::lock_conn!(db.conn);
let (model, request_model, total_cost, cost_multiplier): (String, String, String, String) =
conn.query_row(
"SELECT model, request_model, total_cost_usd, cost_multiplier
FROM proxy_request_logs WHERE provider_id = ?1",
["provider-1"],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.map_err(|e| AppError::Database(e.to_string()))?;
assert_eq!(model, "resp-model");
assert_eq!(request_model, "req-model");
assert_eq!(
Decimal::from_str(&cost_multiplier).unwrap(),
Decimal::from_str("2").unwrap()
);
assert_eq!(
Decimal::from_str(&total_cost).unwrap(),
Decimal::from_str("4").unwrap()
);
Ok(())
}
#[tokio::test]
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
let app_type = "claude";
db.set_default_cost_multiplier(app_type, "1.5").await?;
db.set_pricing_model_source(app_type, "response").await?;
seed_pricing(&db)?;
let meta = ProviderMeta::default();
insert_provider(&db, "provider-2", app_type, meta)?;
let state = build_state(db.clone());
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
};
log_usage_internal(
&state,
"provider-2",
app_type,
"resp-model",
"req-model",
usage,
10,
None,
false,
200,
None,
)
.await;
let conn = crate::database::lock_conn!(db.conn);
let (total_cost, cost_multiplier): (String, String) = conn
.query_row(
"SELECT total_cost_usd, cost_multiplier
FROM proxy_request_logs WHERE provider_id = ?1",
["provider-2"],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|e| AppError::Database(e.to_string()))?;
assert_eq!(
Decimal::from_str(&cost_multiplier).unwrap(),
Decimal::from_str("1.5").unwrap()
);
assert_eq!(
Decimal::from_str(&total_cost).unwrap(),
Decimal::from_str("1.5").unwrap()
);
Ok(())
}
}
-3
View File
@@ -98,9 +98,6 @@ impl ProxyServer {
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
// 更新全局代理端口,用于系统代理检测
crate::proxy::http_client::set_proxy_port(self.config.listen_port);
// 保存关闭句柄
*self.shutdown_tx.write().await = Some(shutdown_tx);
@@ -0,0 +1,10 @@
//! Anthropic ↔ OpenAI 格式转换模块
//!
//! 提供 Anthropic Messages API 和 OpenAI Chat Completions API 之间的双向转换
mod request;
mod response;
pub mod streaming;
pub use request::AnthropicToOpenAITransformer;
pub use response::OpenAIToAnthropicTransformer;
@@ -1,18 +1,76 @@
//! 格式转换模块
//! Anthropic → OpenAI 请求转换器
//!
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
//! 参考: anthropic-proxy-rs
//! Anthropic Messages API 请求转换为 OpenAI Chat Completions API 格式
use crate::proxy::error::ProxyError;
use crate::proxy::transform::{format::ApiFormat, traits::FormatTransformer};
use bytes::Bytes;
use futures::stream::Stream;
use serde_json::{json, Value};
use std::pin::Pin;
/// Anthropic → OpenAI 请求转换器
pub struct AnthropicToOpenAITransformer;
impl AnthropicToOpenAITransformer {
pub fn new() -> Self {
Self
}
}
impl Default for AnthropicToOpenAITransformer {
fn default() -> Self {
Self::new()
}
}
impl FormatTransformer for AnthropicToOpenAITransformer {
fn name(&self) -> &'static str {
"Anthropic→OpenAI"
}
fn source_format(&self) -> ApiFormat {
ApiFormat::Anthropic
}
fn target_format(&self) -> ApiFormat {
ApiFormat::OpenAI
}
fn transform_request(&self, body: Value) -> Result<Value, ProxyError> {
anthropic_to_openai(body)
}
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
// 请求转换器不处理响应,直接透传
Ok(body)
}
fn transform_stream(
&self,
_stream: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
// 请求转换器不处理流
Box::pin(futures::stream::empty())
}
fn transform_endpoint(&self, endpoint: &str) -> String {
// /v1/messages → /v1/chat/completions
if endpoint == "/v1/messages" {
"/v1/chat/completions".to_string()
} else {
endpoint.to_string()
}
}
}
/// Anthropic 请求 → OpenAI 请求
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
let mut result = json!({});
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
result["model"] = json!(model);
// 模型直接透传(模型映射由 model_mapper 模块独立处理)
if let Some(model) = body.get("model") {
result["model"] = model.clone();
}
let mut messages = Vec::new();
@@ -231,91 +289,6 @@ fn clean_schema(mut schema: Value) -> Value {
schema
}
/// OpenAI 响应 → Anthropic 响应
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let choices = body
.get("choices")
.and_then(|c| c.as_array())
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
let choice = choices
.first()
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
let message = choice
.get("message")
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
let mut content = Vec::new();
// 文本内容
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
}
// 工具调用
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
let empty_obj = json!({});
let func = tc.get("function").unwrap_or(&empty_obj);
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args_str = func
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
// 映射 finish_reason → stop_reason
let stop_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(|r| match r {
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
other => other,
});
// usage
let usage = body.get("usage").cloned().unwrap_or(json!({}));
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let result = json!({
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": content,
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
"stop_reason": stop_reason,
"stop_sequence": null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
});
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -413,71 +386,12 @@ mod tests {
}
#[test]
fn test_openai_to_anthropic_simple() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["id"], "chatcmpl-123");
assert_eq!(result["type"], "message");
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello!");
assert_eq!(result["stop_reason"], "end_turn");
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
}
#[test]
fn test_openai_to_anthropic_with_tool_calls() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["id"], "call_123");
assert_eq!(result["content"][0]["name"], "get_weather");
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_model_passthrough() {
// 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "gpt-4o");
fn test_transform_endpoint() {
let transformer = AnthropicToOpenAITransformer::new();
assert_eq!(
transformer.transform_endpoint("/v1/messages"),
"/v1/chat/completions"
);
assert_eq!(transformer.transform_endpoint("/v1/other"), "/v1/other");
}
}
@@ -0,0 +1,237 @@
//! OpenAI → Anthropic 响应转换器
//!
//! 将 OpenAI Chat Completions API 响应转换为 Anthropic Messages API 格式
use crate::proxy::error::ProxyError;
use crate::proxy::transform::{format::ApiFormat, traits::FormatTransformer};
use bytes::Bytes;
use futures::stream::Stream;
use serde_json::{json, Value};
use std::pin::Pin;
use super::streaming::create_anthropic_sse_stream;
/// OpenAI → Anthropic 响应转换器
pub struct OpenAIToAnthropicTransformer;
impl OpenAIToAnthropicTransformer {
pub fn new() -> Self {
Self
}
}
impl Default for OpenAIToAnthropicTransformer {
fn default() -> Self {
Self::new()
}
}
impl FormatTransformer for OpenAIToAnthropicTransformer {
fn name(&self) -> &'static str {
"OpenAI→Anthropic"
}
fn source_format(&self) -> ApiFormat {
ApiFormat::OpenAI
}
fn target_format(&self) -> ApiFormat {
ApiFormat::Anthropic
}
fn transform_request(&self, body: Value) -> Result<Value, ProxyError> {
// 响应转换器不处理请求,直接透传
Ok(body)
}
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
openai_to_anthropic(body)
}
fn transform_stream(
&self,
stream: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
Box::pin(create_anthropic_sse_stream(stream))
}
}
/// OpenAI 响应 → Anthropic 响应
fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let choices = body
.get("choices")
.and_then(|c| c.as_array())
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
let choice = choices
.first()
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
let message = choice
.get("message")
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
let mut content = Vec::new();
// 文本内容
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
}
// 工具调用
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
let empty_obj = json!({});
let func = tc.get("function").unwrap_or(&empty_obj);
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args_str = func
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
// 解析 arguments JSON,失败时返回错误而不是静默使用空对象
let input: Value = serde_json::from_str(args_str).map_err(|e| {
log::error!("[Transform] tool_calls.arguments 解析失败: {e}, 原始内容: {args_str}");
ProxyError::TransformError(format!(
"Failed to parse tool_calls.arguments: {e}, content: {args_str}"
))
})?;
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
// 映射 finish_reason → stop_reason
let stop_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(|r| match r {
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
other => other,
});
// usage
let usage = body.get("usage").cloned().unwrap_or(json!({}));
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let result = json!({
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": content,
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
"stop_reason": stop_reason,
"stop_sequence": null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
});
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_openai_to_anthropic_simple() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["id"], "chatcmpl-123");
assert_eq!(result["type"], "message");
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello!");
assert_eq!(result["stop_reason"], "end_turn");
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
}
#[test]
fn test_openai_to_anthropic_with_tool_calls() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["id"], "call_123");
assert_eq!(result["content"][0]["name"], "get_weather");
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_stop_reason_mapping() {
// stop → end_turn
let input = json!({
"choices": [{"message": {"content": "Hi"}, "finish_reason": "stop"}],
"usage": {}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["stop_reason"], "end_turn");
// length → max_tokens
let input = json!({
"choices": [{"message": {"content": "Hi"}, "finish_reason": "length"}],
"usage": {}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["stop_reason"], "max_tokens");
// tool_calls → tool_use
let input = json!({
"choices": [{"message": {"content": null, "tool_calls": []}, "finish_reason": "tool_calls"}],
"usage": {}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["stop_reason"], "tool_use");
}
}
@@ -6,6 +6,7 @@ use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
/// OpenAI 流式响应数据结构
#[derive(Debug, Deserialize)]
@@ -73,7 +74,8 @@ pub fn create_anthropic_sse_stream(
let mut content_index = 0;
let mut has_sent_message_start = false;
let mut current_block_type: Option<String> = None;
let mut tool_call_id = None;
// 使用 HashMap 按 index 管理多个工具调用的 ID 和 content_index
let mut tool_calls_map: HashMap<usize, (String, usize)> = HashMap::new();
tokio::pin!(stream);
@@ -94,17 +96,17 @@ pub fn create_anthropic_sse_stream(
for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
log::debug!("[Transform] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
log::debug!("[Transform] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data));
continue;
}
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
log::debug!("[Transform] <<< SSE chunk received");
if message_id.is_none() {
message_id = Some(chunk.id.clone());
@@ -210,7 +212,11 @@ pub fn create_anthropic_sse_stream(
// 处理工具调用
if let Some(tool_calls) = &choice.delta.tool_calls {
for tool_call in tool_calls {
let tc_index = tool_call.index;
// 检查是否是新的工具调用(有 id 表示开始新的工具调用)
if let Some(id) = &tool_call.id {
// 关闭当前的 content block(如果有)
if current_block_type.is_some() {
let event = json!({
"type": "content_block_stop",
@@ -222,30 +228,44 @@ pub fn create_anthropic_sse_stream(
content_index += 1;
}
tool_call_id = Some(id.clone());
// 记录这个工具调用的 ID 和对应的 content_index
tool_calls_map.insert(tc_index, (id.clone(), content_index));
current_block_type = Some("tool_use".to_string());
}
// 获取当前工具调用的信息
let (tool_id, tool_content_index) = tool_calls_map
.get(&tc_index)
.cloned()
.unwrap_or_else(|| {
log::warn!(
"[Transform] 收到未知 index 的工具调用 delta: {tc_index}"
);
(String::new(), content_index)
});
if let Some(function) = &tool_call.function {
// 如果有 name,发送 content_block_start
if let Some(name) = &function.name {
let event = json!({
"type": "content_block_start",
"index": content_index,
"index": tool_content_index,
"content_block": {
"type": "tool_use",
"id": tool_call_id.clone().unwrap_or_default(),
"id": tool_id,
"name": name
}
});
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
current_block_type = Some("tool_use".to_string());
}
// 如果有 arguments,发送 content_block_delta
if let Some(args) = &function.arguments {
let event = json!({
"type": "content_block_delta",
"index": content_index,
"index": tool_content_index,
"delta": {
"type": "input_json_delta",
"partial_json": args
+247
View File
@@ -0,0 +1,247 @@
//! 格式转换配置
//!
//! 从 Provider 配置中提取格式转换设置
use super::format::ApiFormat;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
/// 格式转换配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransformConfig {
/// 是否启用格式转换
pub enabled: bool,
/// 源格式(客户端发送的格式)
pub source_format: ApiFormat,
/// 目标格式(上游服务期望的格式)
pub target_format: ApiFormat,
/// 是否转换流式响应
pub transform_streaming: bool,
}
impl Default for TransformConfig {
fn default() -> Self {
Self {
enabled: false,
source_format: ApiFormat::Anthropic,
target_format: ApiFormat::OpenAI,
transform_streaming: true,
}
}
}
impl TransformConfig {
/// 从 Provider 配置中提取转换配置
///
/// 优先级:
/// 1. ProviderMeta.format_transform(新配置格式,通过前端 UI 设置)
/// 2. settings_config.format_transform(兼容旧配置)
/// 3. settings_config.openrouter_compat_mode(兼容旧配置)
///
/// 注意:如果格式解析失败,将禁用转换并记录警告,而不是静默回退到默认值
pub fn from_provider(provider: &Provider) -> Self {
// 1. 优先从 ProviderMeta 读取(前端 UI 设置的配置)
if let Some(meta) = &provider.meta {
if let Some(ft) = &meta.format_transform {
if ft.enabled {
let source_str = ft.source_format.as_deref();
let target_str = ft.target_format.as_deref();
let source_format = source_str.and_then(ApiFormat::from_str);
let target_format = target_str.and_then(ApiFormat::from_str);
// 如果格式解析失败,禁用转换并记录警告
if source_str.is_some() && source_format.is_none() {
log::warn!(
"[TransformConfig] 无法解析 source_format: {source_str:?},禁用格式转换"
);
return Self::default();
}
if target_str.is_some() && target_format.is_none() {
log::warn!(
"[TransformConfig] 无法解析 target_format: {target_str:?},禁用格式转换"
);
return Self::default();
}
let transform_streaming = ft.transform_streaming.unwrap_or(true);
return Self {
enabled: true,
source_format: source_format.unwrap_or(ApiFormat::Anthropic),
target_format: target_format.unwrap_or(ApiFormat::OpenAI),
transform_streaming,
};
}
}
}
let settings = &provider.settings_config;
// 2. 检查是否显式启用格式转换(settings_config 中的配置)
let format_transform = settings.get("format_transform").and_then(|v| v.as_object());
if let Some(config) = format_transform {
let enabled = config
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if enabled {
let source_str = config.get("source_format").and_then(|v| v.as_str());
let target_str = config.get("target_format").and_then(|v| v.as_str());
let source_format = source_str.and_then(ApiFormat::from_str);
let target_format = target_str.and_then(ApiFormat::from_str);
// 如果格式解析失败,禁用转换并记录警告
if source_str.is_some() && source_format.is_none() {
log::warn!(
"[TransformConfig] 无法解析 source_format: {source_str:?},禁用格式转换"
);
return Self::default();
}
if target_str.is_some() && target_format.is_none() {
log::warn!(
"[TransformConfig] 无法解析 target_format: {target_str:?},禁用格式转换"
);
return Self::default();
}
let transform_streaming = config
.get("transform_streaming")
.and_then(|v| v.as_bool())
.unwrap_or(true);
return Self {
enabled,
source_format: source_format.unwrap_or(ApiFormat::Anthropic),
target_format: target_format.unwrap_or(ApiFormat::OpenAI),
transform_streaming,
};
}
}
// 3. 兼容旧配置:检查 openrouter_compat_mode
let legacy_enabled = settings
.get("openrouter_compat_mode")
.and_then(|v| match v {
serde_json::Value::Bool(b) => Some(*b),
serde_json::Value::Number(n) => Some(n.as_i64().unwrap_or(0) != 0),
serde_json::Value::String(s) => {
let normalized = s.trim().to_lowercase();
Some(normalized == "true" || normalized == "1")
}
_ => None,
})
.unwrap_or(false);
if legacy_enabled {
return Self {
enabled: true,
source_format: ApiFormat::Anthropic,
target_format: ApiFormat::OpenAI,
transform_streaming: true,
};
}
Self::default()
}
/// 检查是否需要转换
pub fn needs_transform(&self) -> bool {
self.enabled && self.source_format != self.target_format
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(settings: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: settings,
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_default_config() {
let provider = create_provider(json!({}));
let config = TransformConfig::from_provider(&provider);
assert!(!config.enabled);
assert!(!config.needs_transform());
}
#[test]
fn test_new_format_config() {
let provider = create_provider(json!({
"format_transform": {
"enabled": true,
"source_format": "anthropic",
"target_format": "openai",
"transform_streaming": true
}
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
assert_eq!(config.source_format, ApiFormat::Anthropic);
assert_eq!(config.target_format, ApiFormat::OpenAI);
assert!(config.transform_streaming);
assert!(config.needs_transform());
}
#[test]
fn test_legacy_openrouter_compat_mode_bool() {
let provider = create_provider(json!({
"openrouter_compat_mode": true
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
assert_eq!(config.source_format, ApiFormat::Anthropic);
assert_eq!(config.target_format, ApiFormat::OpenAI);
}
#[test]
fn test_legacy_openrouter_compat_mode_string() {
let provider = create_provider(json!({
"openrouter_compat_mode": "true"
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
}
#[test]
fn test_legacy_openrouter_compat_mode_number() {
let provider = create_provider(json!({
"openrouter_compat_mode": 1
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
}
#[test]
fn test_same_format_no_transform() {
let provider = create_provider(json!({
"format_transform": {
"enabled": true,
"source_format": "anthropic",
"target_format": "anthropic"
}
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
assert!(!config.needs_transform()); // 相同格式不需要转换
}
}
+67
View File
@@ -0,0 +1,67 @@
//! API 格式枚举定义
//!
//! 定义支持的 API 格式类型,用于格式转换配置
use serde::{Deserialize, Serialize};
/// API 格式枚举
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiFormat {
/// Anthropic Messages API
Anthropic,
/// OpenAI Chat Completions API
OpenAI,
/// Google Gemini API (预留)
Gemini,
}
impl ApiFormat {
/// 从字符串解析
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"anthropic" | "claude" => Some(Self::Anthropic),
"openai" | "codex" => Some(Self::OpenAI),
"gemini" | "google" => Some(Self::Gemini),
_ => None,
}
}
/// 转换为字符串
pub fn as_str(&self) -> &'static str {
match self {
Self::Anthropic => "anthropic",
Self::OpenAI => "openai",
Self::Gemini => "gemini",
}
}
}
impl std::fmt::Display for ApiFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_str() {
assert_eq!(ApiFormat::from_str("anthropic"), Some(ApiFormat::Anthropic));
assert_eq!(ApiFormat::from_str("claude"), Some(ApiFormat::Anthropic));
assert_eq!(ApiFormat::from_str("openai"), Some(ApiFormat::OpenAI));
assert_eq!(ApiFormat::from_str("codex"), Some(ApiFormat::OpenAI));
assert_eq!(ApiFormat::from_str("gemini"), Some(ApiFormat::Gemini));
assert_eq!(ApiFormat::from_str("google"), Some(ApiFormat::Gemini));
assert_eq!(ApiFormat::from_str("unknown"), None);
}
#[test]
fn test_as_str() {
assert_eq!(ApiFormat::Anthropic.as_str(), "anthropic");
assert_eq!(ApiFormat::OpenAI.as_str(), "openai");
assert_eq!(ApiFormat::Gemini.as_str(), "gemini");
}
}
+36
View File
@@ -0,0 +1,36 @@
//! 通用格式转换模块
//!
//! 提供 API 格式之间的双向转换,支持:
//! - Anthropic ↔ OpenAI
//! - Gemini ↔ OpenAI(预留)
//!
//! ## 使用方式
//!
//! ```rust,ignore
//! use crate::proxy::transform::{config::TransformConfig, registry::get_transformer};
//!
//! let config = TransformConfig::from_provider(&provider);
//! if config.needs_transform() {
//! if let Some(transformer) = get_transformer(config.source_format, config.target_format) {
//! let transformed = transformer.transform_request(body)?;
//! }
//! }
//! ```
pub mod anthropic_openai;
pub mod config;
pub mod format;
pub mod registry;
pub mod traits;
// 公开导出
pub use config::TransformConfig;
pub use registry::get_transformer;
// 以下导出供外部模块使用(如需扩展转换器)
#[allow(unused_imports)]
pub use format::ApiFormat;
#[allow(unused_imports)]
pub use registry::TRANSFORMER_REGISTRY;
#[allow(unused_imports)]
pub use traits::{BidirectionalTransformer, FormatTransformer};
+95
View File
@@ -0,0 +1,95 @@
//! 转换器注册表
//!
//! 管理和获取格式转换器
use super::{format::ApiFormat, traits::FormatTransformer};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
/// 转换器注册表
pub struct TransformerRegistry {
transformers: HashMap<(ApiFormat, ApiFormat), Arc<dyn FormatTransformer>>,
}
impl TransformerRegistry {
/// 创建新的注册表
pub fn new() -> Self {
let mut registry = Self {
transformers: HashMap::new(),
};
registry.register_defaults();
registry
}
/// 注册默认转换器
fn register_defaults(&mut self) {
use super::anthropic_openai::{AnthropicToOpenAITransformer, OpenAIToAnthropicTransformer};
// Anthropic → OpenAI
self.register(Arc::new(AnthropicToOpenAITransformer::new()));
// OpenAI → Anthropic
self.register(Arc::new(OpenAIToAnthropicTransformer::new()));
}
/// 注册转换器
pub fn register(&mut self, transformer: Arc<dyn FormatTransformer>) {
let key = (transformer.source_format(), transformer.target_format());
self.transformers.insert(key, transformer);
}
/// 获取转换器
pub fn get(&self, source: ApiFormat, target: ApiFormat) -> Option<Arc<dyn FormatTransformer>> {
self.transformers.get(&(source, target)).cloned()
}
/// 检查是否支持指定的转换
#[cfg(test)]
pub fn supports(&self, source: ApiFormat, target: ApiFormat) -> bool {
self.transformers.contains_key(&(source, target))
}
}
impl Default for TransformerRegistry {
fn default() -> Self {
Self::new()
}
}
/// 全局转换器注册表
pub static TRANSFORMER_REGISTRY: LazyLock<TransformerRegistry> =
LazyLock::new(TransformerRegistry::new);
/// 获取转换器的便捷函数
pub fn get_transformer(source: ApiFormat, target: ApiFormat) -> Option<Arc<dyn FormatTransformer>> {
TRANSFORMER_REGISTRY.get(source, target)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_registry_has_default_transformers() {
let registry = TransformerRegistry::new();
// Anthropic → OpenAI
assert!(registry.supports(ApiFormat::Anthropic, ApiFormat::OpenAI));
// OpenAI → Anthropic
assert!(registry.supports(ApiFormat::OpenAI, ApiFormat::Anthropic));
// 不支持的转换
assert!(!registry.supports(ApiFormat::Gemini, ApiFormat::OpenAI));
}
#[test]
fn test_get_transformer() {
let transformer = get_transformer(ApiFormat::Anthropic, ApiFormat::OpenAI);
assert!(transformer.is_some());
let t = transformer.unwrap();
assert_eq!(t.source_format(), ApiFormat::Anthropic);
assert_eq!(t.target_format(), ApiFormat::OpenAI);
}
}
+47
View File
@@ -0,0 +1,47 @@
//! 格式转换器 Trait 定义
//!
//! 定义通用的格式转换器接口
use super::format::ApiFormat;
use crate::proxy::error::ProxyError;
use bytes::Bytes;
use futures::stream::Stream;
use serde_json::Value;
use std::pin::Pin;
/// 格式转换器 Trait
pub trait FormatTransformer: Send + Sync {
/// 转换器名称(用于日志)
#[allow(dead_code)]
fn name(&self) -> &'static str;
/// 源格式
fn source_format(&self) -> ApiFormat;
/// 目标格式
fn target_format(&self) -> ApiFormat;
/// 转换请求体
fn transform_request(&self, body: Value) -> Result<Value, ProxyError>;
/// 转换非流式响应体
fn transform_response(&self, body: Value) -> Result<Value, ProxyError>;
/// 转换流式响应
fn transform_stream(
&self,
stream: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>;
/// 获取转换后的端点路径
fn transform_endpoint(&self, endpoint: &str) -> String {
endpoint.to_string()
}
}
/// 双向转换器 Trait(可选实现)
#[allow(dead_code)]
pub trait BidirectionalTransformer: FormatTransformer {
/// 获取反向转换器
fn reverse(&self) -> Box<dyn FormatTransformer>;
}
+13 -14
View File
@@ -40,7 +40,6 @@ impl CostCalculator {
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
/// - 这样避免缓存部分被重复计费
/// - total_cost: 各项成本之和 × 倍率(倍率只作用于最终总价)
pub fn calculate(
usage: &TokenUsage,
pricing: &ModelPricing,
@@ -51,20 +50,21 @@ impl CostCalculator {
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
// 各项基础成本(不含倍率)
let input_cost =
Decimal::from(billable_input_tokens) * pricing.input_cost_per_million / million;
let output_cost =
Decimal::from(usage.output_tokens) * pricing.output_cost_per_million / million;
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
/ million
* cost_multiplier;
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
/ million
* cost_multiplier;
let cache_read_cost =
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million;
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million
* cost_multiplier;
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
* pricing.cache_creation_cost_per_million
/ million;
/ million
* cost_multiplier;
// 总成本 = 各项基础成本之和 × 倍率
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
let total_cost = base_total * cost_multiplier;
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
CostBreakdown {
input_cost,
@@ -151,9 +151,8 @@ mod tests {
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// input_cost: 基础价格(不含倍率)= 1000 * 3.0 / 1M = 0.003
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
// total_cost: 基础价格 × 倍率 = 0.003 * 1.5 = 0.0045
// input: 1000 * 3.0 / 1M * 1.5 = 0.0045
assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
}
+8 -102
View File
@@ -6,7 +6,7 @@ use crate::database::Database;
use crate::error::AppError;
use crate::services::usage_stats::find_model_pricing_row;
use rust_decimal::Decimal;
use std::{str::FromStr, time::SystemTime};
use std::time::SystemTime;
/// 请求日志
#[derive(Debug, Clone)]
@@ -15,7 +15,6 @@ pub struct RequestLog {
pub provider_id: String,
pub app_type: String,
pub model: String,
pub request_model: String,
pub usage: TokenUsage,
pub cost: Option<CostBreakdown>,
pub latency_ms: u64,
@@ -74,18 +73,17 @@ impl<'a> UsageLogger<'a> {
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
request_id, provider_id, app_type, model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)",
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
rusqlite::params![
log.request_id,
log.provider_id,
log.app_type,
log.model,
log.request_model,
log.usage.input_tokens,
log.usage.output_tokens,
log.usage.cache_read_tokens,
@@ -125,13 +123,11 @@ impl<'a> UsageLogger<'a> {
error_message: String,
latency_ms: u64,
) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
request_model,
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -164,13 +160,11 @@ impl<'a> UsageLogger<'a> {
session_id: Option<String>,
provider_type: Option<String>,
) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
request_model,
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -200,88 +194,6 @@ impl<'a> UsageLogger<'a> {
}
}
/// 获取有效的倍率与计费模式来源(供应商优先,未配置则回退全局默认)
pub async fn resolve_pricing_config(
&self,
provider_id: &str,
app_type: &str,
) -> (Decimal, String) {
let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
"1".to_string()
}
};
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
Ok(value) => value,
Err(e) => {
log::warn!(
"[USG-003] 默认倍率解析失败 (app_type={app_type}): {default_multiplier_raw} - {e}"
);
Decimal::from(1)
}
};
let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
"response".to_string()
}
};
let default_pricing_source =
if matches!(default_pricing_source_raw.as_str(), "response" | "request") {
default_pricing_source_raw
} else {
log::warn!(
"[USG-003] 默认计费模式无效 (app_type={app_type}): {default_pricing_source_raw}"
);
"response".to_string()
};
let provider = self
.db
.get_provider_by_id(provider_id, app_type)
.ok()
.flatten();
let (provider_multiplier, provider_pricing_source) = provider
.as_ref()
.and_then(|p| p.meta.as_ref())
.map(|meta| {
(
meta.cost_multiplier.as_deref(),
meta.pricing_model_source.as_deref(),
)
})
.unwrap_or((None, None));
let cost_multiplier = match provider_multiplier {
Some(value) => match Decimal::from_str(value) {
Ok(parsed) => parsed,
Err(e) => {
log::warn!(
"[USG-003] 供应商倍率解析失败 (provider_id={provider_id}): {value} - {e}"
);
default_multiplier
}
},
None => default_multiplier,
};
let pricing_model_source = match provider_pricing_source {
Some(value) if matches!(value, "response" | "request") => value.to_string(),
Some(value) => {
log::warn!("[USG-003] 供应商计费模式无效 (provider_id={provider_id}): {value}");
default_pricing_source.clone()
}
None => default_pricing_source.clone(),
};
(cost_multiplier, pricing_model_source)
}
/// 计算并记录请求
#[allow(clippy::too_many_arguments)]
pub fn log_with_calculation(
@@ -290,8 +202,6 @@ impl<'a> UsageLogger<'a> {
provider_id: String,
app_type: String,
model: String,
request_model: String,
pricing_model: String,
usage: TokenUsage,
cost_multiplier: Decimal,
latency_ms: u64,
@@ -301,10 +211,10 @@ impl<'a> UsageLogger<'a> {
provider_type: Option<String>,
is_streaming: bool,
) -> Result<(), AppError> {
let pricing = self.get_model_pricing(&pricing_model)?;
let pricing = self.get_model_pricing(&model)?;
if pricing.is_none() {
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}");
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
}
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
@@ -314,7 +224,6 @@ impl<'a> UsageLogger<'a> {
provider_id,
app_type,
model,
request_model,
usage,
cost,
latency_ms,
@@ -365,8 +274,6 @@ mod tests {
"provider-1".to_string(),
"claude".to_string(),
"test-model".to_string(),
"req-model".to_string(),
"test-model".to_string(),
usage,
Decimal::from(1),
100,
@@ -379,15 +286,14 @@ mod tests {
// 验证记录已插入
let conn = crate::database::lock_conn!(db.conn);
let (count, request_model): (i64, String) = conn
let count: i64 = conn
.query_row(
"SELECT COUNT(*), request_model FROM proxy_request_logs WHERE request_id = 'req-123'",
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'req-123'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
assert_eq!(request_model, "req-model");
Ok(())
}
+2 -3
View File
@@ -1,4 +1,4 @@
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
use super::provider::ProviderService;
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
@@ -181,8 +181,7 @@ impl ConfigService {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
write_json_file(&settings_path, &settings)?;
write_json_file(&settings_path, &provider.settings_config)?;
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
-2
View File
@@ -2,7 +2,6 @@ pub mod config;
pub mod env_checker;
pub mod env_manager;
pub mod mcp;
pub mod omo;
pub mod prompt;
pub mod provider;
pub mod proxy;
@@ -13,7 +12,6 @@ pub mod usage_stats;
pub use config::ConfigService;
pub use mcp::McpService;
pub use omo::OmoService;
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService;
-437
View File
@@ -1,437 +0,0 @@
use crate::config::write_json_file;
use crate::database::OmoGlobalConfig;
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::store::AppState;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OmoLocalFileData {
pub agents: Option<Value>,
pub categories: Option<Value>,
pub other_fields: Option<Value>,
pub global: OmoGlobalConfig,
pub file_path: String,
pub last_modified: Option<String>,
}
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
pub struct OmoService;
impl OmoService {
fn config_path() -> PathBuf {
get_opencode_dir().join("oh-my-opencode.jsonc")
}
fn resolve_local_config_path() -> Result<PathBuf, AppError> {
let config_path = Self::config_path();
if config_path.exists() {
return Ok(config_path);
}
let json_path = config_path.with_extension("json");
if json_path.exists() {
return Ok(json_path);
}
Err(AppError::OmoConfigNotFound)
}
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let cleaned = Self::strip_jsonc_comments(&content);
let parsed: Value = serde_json::from_str(&cleaned)
.map_err(|e| AppError::Config(format!("Failed to parse oh-my-opencode config: {e}")))?;
parsed
.as_object()
.cloned()
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
}
fn extract_other_fields(obj: &Map<String, Value>) -> Map<String, Value> {
const KNOWN_KEYS: [&str; 13] = [
"$schema",
"agents",
"categories",
"sisyphus_agent",
"disabled_agents",
"disabled_mcps",
"disabled_hooks",
"disabled_skills",
"lsp",
"experimental",
"background_task",
"browser_automation_engine",
"claude_code",
];
let mut other = Map::new();
for (k, v) in obj {
if !KNOWN_KEYS.contains(&k.as_str()) {
other.insert(k.clone(), v.clone());
}
}
other
}
fn extract_string_array(val: &Value) -> Vec<String> {
val.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn merge_global_from_obj(obj: &Map<String, Value>, global: &mut OmoGlobalConfig) {
if let Some(v) = obj.get("$schema") {
global.schema_url = v.as_str().map(|s| s.to_string());
}
for (key, target) in [
("disabled_agents", &mut global.disabled_agents),
("disabled_mcps", &mut global.disabled_mcps),
("disabled_hooks", &mut global.disabled_hooks),
("disabled_skills", &mut global.disabled_skills),
] {
if let Some(v) = obj.get(key) {
*target = Self::extract_string_array(v);
}
}
for (key, target) in [
("sisyphus_agent", &mut global.sisyphus_agent),
("lsp", &mut global.lsp),
("experimental", &mut global.experimental),
("background_task", &mut global.background_task),
(
"browser_automation_engine",
&mut global.browser_automation_engine,
),
("claude_code", &mut global.claude_code),
] {
if let Some(v) = obj.get(key) {
*target = Some(v.clone());
}
}
}
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
if let Some(v) = value {
result.insert(key.to_string(), v.clone());
}
}
fn insert_string_array(result: &mut Map<String, Value>, key: &str, values: &[String]) {
if !values.is_empty() {
result.insert(
key.to_string(),
serde_json::to_value(values).unwrap_or(Value::Array(vec![])),
);
}
}
fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {
if let Some(Value::Object(map)) = value {
for (k, v) in map {
result.insert(k.clone(), v.clone());
}
}
}
pub fn delete_config_file() -> Result<(), AppError> {
let config_path = Self::config_path();
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
log::info!("OMO config file deleted: {config_path:?}");
}
crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?;
Ok(())
}
pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> {
let global = state.db.get_omo_global_config()?;
let current_omo = state.db.get_current_omo_provider("opencode")?;
let profile_data = current_omo.as_ref().map(|p| {
let agents = p.settings_config.get("agents").cloned();
let categories = p.settings_config.get("categories").cloned();
let other_fields = p.settings_config.get("otherFields").cloned();
let use_common_config = p
.settings_config
.get("useCommonConfig")
.and_then(|v| v.as_bool())
.unwrap_or(true);
(agents, categories, other_fields, use_common_config)
});
let merged = Self::merge_config(&global, profile_data.as_ref());
let config_path = Self::config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
write_json_file(&config_path, &merged)?;
crate::opencode_config::add_plugin("oh-my-opencode@latest")?;
log::info!("OMO config written to {config_path:?}");
Ok(())
}
fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value {
let mut result = Map::new();
let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true);
if use_common_config {
if let Some(url) = &global.schema_url {
result.insert("$schema".to_string(), Value::String(url.clone()));
}
Self::insert_opt_value(&mut result, "sisyphus_agent", &global.sisyphus_agent);
Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents);
Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps);
Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks);
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
Self::insert_opt_value(
&mut result,
"browser_automation_engine",
&global.browser_automation_engine,
);
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
Self::insert_object_entries(&mut result, global.other_fields.as_ref());
}
if let Some((agents, categories, other_fields, _)) = profile_data {
Self::insert_opt_value(&mut result, "agents", agents);
Self::insert_opt_value(&mut result, "categories", categories);
Self::insert_object_entries(&mut result, other_fields.as_ref());
}
Value::Object(result)
}
pub fn import_from_local(state: &AppState) -> Result<crate::provider::Provider, AppError> {
let actual_path = Self::resolve_local_config_path()?;
Self::import_from_path(state, &actual_path)
}
fn import_from_path(
state: &AppState,
path: &std::path::Path,
) -> Result<crate::provider::Provider, AppError> {
let obj = Self::read_jsonc_object(path)?;
let mut settings = Map::new();
if let Some(agents) = obj.get("agents") {
settings.insert("agents".to_string(), agents.clone());
}
if let Some(categories) = obj.get("categories") {
settings.insert("categories".to_string(), categories.clone());
}
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
let other = Self::extract_other_fields(&obj);
if !other.is_empty() {
settings.insert("otherFields".to_string(), Value::Object(other));
}
let mut global = state.db.get_omo_global_config()?;
Self::merge_global_from_obj(&obj, &mut global);
global.updated_at = chrono::Utc::now().to_rfc3339();
state.db.save_omo_global_config(&global)?;
let provider_id = format!("omo-{}", uuid::Uuid::new_v4());
let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M"));
let settings_config =
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
let provider = crate::provider::Provider {
id: provider_id,
name,
settings_config,
website_url: None,
category: Some("omo".to_string()),
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
};
state.db.save_provider("opencode", &provider)?;
state
.db
.set_omo_provider_current("opencode", &provider.id)?;
Self::write_config_to_file(state)?;
Ok(provider)
}
pub fn read_local_file() -> Result<OmoLocalFileData, AppError> {
let actual_path = Self::resolve_local_config_path()?;
let metadata = std::fs::metadata(&actual_path).ok();
let last_modified = metadata
.and_then(|m| m.modified().ok())
.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339());
let obj = Self::read_jsonc_object(&actual_path)?;
let agents = obj.get("agents").cloned();
let categories = obj.get("categories").cloned();
let other = Self::extract_other_fields(&obj);
let other_fields = if other.is_empty() {
None
} else {
Some(Value::Object(other))
};
let mut global = OmoGlobalConfig::default();
Self::merge_global_from_obj(&obj, &mut global);
Ok(OmoLocalFileData {
agents,
categories,
other_fields,
global,
file_path: actual_path.to_string_lossy().to_string(),
last_modified,
})
}
fn strip_jsonc_comments(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escape = false;
while let Some(&c) = chars.peek() {
if in_string {
result.push(c);
chars.next();
if escape {
escape = false;
} else if c == '\\' {
escape = true;
} else if c == '"' {
in_string = false;
}
} else if c == '"' {
in_string = true;
result.push(c);
chars.next();
} else if c == '/' {
chars.next();
match chars.peek() {
Some('/') => {
chars.next();
while let Some(&nc) = chars.peek() {
if nc == '\n' {
break;
}
chars.next();
}
}
Some('*') => {
chars.next();
while let Some(nc) = chars.next() {
if nc == '*' {
if let Some(&'/') = chars.peek() {
chars.next();
break;
}
}
}
}
_ => {
result.push('/');
}
}
} else {
result.push(c);
chars.next();
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_jsonc_comments() {
let input = r#"{
// This is a comment
"key": "value", // inline comment
/* multi
line */
"key2": "val//ue"
}"#;
let result = OmoService::strip_jsonc_comments(input);
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["key"], "value");
assert_eq!(parsed["key2"], "val//ue");
}
#[test]
fn test_merge_config_empty() {
let global = OmoGlobalConfig::default();
let merged = OmoService::merge_config(&global, None);
assert!(merged.is_object());
}
#[test]
fn test_merge_config_with_profile() {
let global = OmoGlobalConfig {
schema_url: Some("https://example.com/schema.json".to_string()),
disabled_agents: vec!["explore".to_string()],
..Default::default()
};
let agents = Some(serde_json::json!({
"Sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert_eq!(obj["$schema"], "https://example.com/schema.json");
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
assert!(obj.contains_key("agents"));
assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5");
}
#[test]
fn test_merge_config_without_common_config() {
let global = OmoGlobalConfig {
schema_url: Some("https://example.com/schema.json".to_string()),
disabled_agents: vec!["explore".to_string()],
..Default::default()
};
let agents = Some(serde_json::json!({
"Sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
let profile_data = (agents, categories, other_fields, false);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert!(!obj.contains_key("$schema"));
assert!(!obj.contains_key("disabled_agents"));
assert!(obj.contains_key("agents"));
}
}
+14 -61
View File
@@ -19,18 +19,6 @@ use super::gemini_auth::{
};
use super::normalize_claude_models_in_value;
pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
let mut v = settings.clone();
if let Some(obj) = v.as_object_mut() {
// Internal-only fields - never write to Claude Code settings.json
obj.remove("api_format");
obj.remove("apiFormat");
obj.remove("openrouter_compat_mode");
obj.remove("openrouterCompatMode");
}
v
}
/// Live configuration snapshot for backup/restore
#[derive(Clone)]
#[allow(dead_code)]
@@ -109,8 +97,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
match app_type {
AppType::Claude => {
let path = get_claude_settings_path();
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
write_json_file(&path, &settings)?;
write_json_file(&path, &provider.settings_config)?;
}
AppType::Codex => {
let obj = provider
@@ -195,67 +182,33 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
Ok(())
}
/// Sync all providers to live configuration (for additive mode apps)
///
/// Writes all providers from the database to the live configuration file.
/// Used for OpenCode and other additive mode applications.
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
for provider in providers.values() {
if let Err(e) = write_live_snapshot(app_type, provider) {
log::warn!(
"Failed to sync {:?} provider '{}' to live: {e}",
app_type,
provider.id
);
// Continue syncing other providers, don't abort
}
}
log::info!(
"Synced {} {:?} providers to live config",
providers.len(),
app_type
);
Ok(())
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
///
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Sync providers based on mode
for app_type in AppType::all() {
if app_type.is_additive_mode() {
// Additive mode: sync ALL providers
sync_all_providers_to_live(state, &app_type)?;
} else {
// Switch mode: sync only current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// Use validated effective current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// MCP sync
McpService::sync_all_enabled(state)?;
// Skill sync
for app_type in AppType::all() {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort
+14 -89
View File
@@ -26,7 +26,6 @@ pub use live::{
};
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::write_live_snapshot;
// Internal re-exports
@@ -164,12 +163,6 @@ impl ProviderService {
// OpenCode uses additive mode - always write to live config
if matches!(app_type, AppType::OpenCode) {
// OMO providers use exclusive mode and write to dedicated config file.
if provider.category.as_deref() == Some("omo") {
// Do not auto-enable newly added OMO providers.
// Users must explicitly switch/apply an OMO provider to activate it.
return Ok(true);
}
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
@@ -203,15 +196,6 @@ impl ProviderService {
// OpenCode uses additive mode - always update in live config
if matches!(app_type, AppType::OpenCode) {
if provider.category.as_deref() == Some("omo") {
let is_omo_current = state
.db
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
if is_omo_current {
crate::services::OmoService::write_config_to_file(state)?;
}
return Ok(true);
}
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
@@ -257,35 +241,6 @@ impl ProviderService {
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// OpenCode uses additive mode - no current provider concept
if matches!(app_type, AppType::OpenCode) {
let is_omo = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category)
.as_deref()
== Some("omo");
if is_omo {
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
let omo_count = state
.db
.get_all_providers(app_type.as_str())?
.values()
.filter(|p| p.category.as_deref() == Some("omo"))
.count();
if omo_count <= 1 && was_current {
return Err(AppError::Message(
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)?;
if was_current {
crate::services::OmoService::delete_config_file()?;
}
return Ok(());
}
// Remove from database
state.db.delete_provider(app_type.as_str(), id)?;
// Also remove from live config
@@ -311,32 +266,10 @@ impl ProviderService {
/// Does NOT delete from database - provider remains in the list.
/// This is used when user wants to "remove" a provider from active config
/// but keep it available for future use.
pub fn remove_from_live_config(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<(), AppError> {
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
match app_type {
AppType::OpenCode => {
let is_omo = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category)
.as_deref()
== Some("omo");
if is_omo {
state.db.clear_omo_provider_current(app_type.as_str(), id)?;
let still_has_current =
state.db.get_current_omo_provider("opencode")?.is_some();
if still_has_current {
crate::services::OmoService::write_config_to_file(state)?;
} else {
crate::services::OmoService::delete_config_file()?;
}
} else {
remove_opencode_provider_from_live(id)?;
}
remove_opencode_provider_from_live(id)?;
}
// Future: add other additive mode apps here
_ => {
@@ -368,11 +301,6 @@ impl ProviderService {
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// OMO providers are switched through their own exclusive path.
if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") {
return Self::switch_normal(state, app_type, id, &providers);
}
// Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function
@@ -444,28 +372,25 @@ impl ProviderService {
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
state.db.set_omo_provider_current(app_type.as_str(), id)?;
crate::services::OmoService::write_config_to_file(state)?;
return Ok(());
}
// Backfill: Backfill current live config to current provider
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
match (current_id, matches!(app_type, AppType::OpenCode)) {
(Some(current_id), false) if current_id != id => {
// Only backfill when switching to a different provider.
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow.
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
if let Some(current_id) = current_id {
if current_id != id {
// OpenCode uses additive mode - all providers coexist in the same file,
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
if !matches!(app_type, AppType::OpenCode) {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
}
}
}
}
_ => {}
}
// OpenCode uses additive mode - skip setting is_current (no such concept)
+1 -2
View File
@@ -1654,8 +1654,7 @@ impl ProxyService {
fn write_claude_live(&self, config: &Value) -> Result<(), String> {
let path = get_claude_settings_path();
let settings = crate::services::provider::sanitize_claude_settings_for_live(config);
write_json_file(&path, &settings).map_err(|e| format!("写入 Claude 配置失败: {e}"))
write_json_file(&path, config).map_err(|e| format!("写入 Claude 配置失败: {e}"))
}
fn read_codex_live(&self) -> Result<Value, String> {
-187
View File
@@ -1110,193 +1110,6 @@ impl SkillService {
Ok(())
}
// ========== 从 ZIP 文件安装 ==========
/// 从本地 ZIP 文件安装 Skills
///
/// 流程:
/// 1. 解压 ZIP 到临时目录
/// 2. 扫描目录查找包含 SKILL.md 的技能
/// 3. 复制到 SSOT 并保存到数据库
/// 4. 同步到当前应用目录
pub fn install_from_zip(
db: &Arc<Database>,
zip_path: &Path,
current_app: &AppType,
) -> Result<Vec<InstalledSkill>> {
// 解压到临时目录
let temp_dir = Self::extract_local_zip(zip_path)?;
// 扫描所有包含 SKILL.md 的目录
let skill_dirs = Self::scan_skills_in_dir(&temp_dir)?;
if skill_dirs.is_empty() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"NO_SKILLS_IN_ZIP",
&[],
Some("checkZipContent"),
)));
}
let ssot_dir = Self::get_ssot_dir()?;
let mut installed = Vec::new();
let existing_skills = db.get_all_installed_skills()?;
for skill_dir in skill_dirs {
// 获取目录名称作为安装名
let install_name = skill_dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
// 检查是否已有同名 directory 的 skill
let conflict = existing_skills
.values()
.find(|s| s.directory.eq_ignore_ascii_case(&install_name));
if let Some(existing) = conflict {
log::warn!(
"Skill directory '{}' already exists (from {}), skipping",
install_name,
existing.id
);
continue;
}
// 解析元数据
let skill_md = skill_dir.join("SKILL.md");
let (name, description) = if skill_md.exists() {
match Self::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| install_name.clone()),
meta.description,
),
Err(_) => (install_name.clone(), None),
}
} else {
(install_name.clone(), None)
};
// 复制到 SSOT
let dest = ssot_dir.join(&install_name);
if dest.exists() {
let _ = fs::remove_dir_all(&dest);
}
Self::copy_dir_recursive(&skill_dir, &dest)?;
// 创建 InstalledSkill 记录
let skill = InstalledSkill {
id: format!("local:{install_name}"),
name,
description,
directory: install_name.clone(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps::only(current_app),
installed_at: chrono::Utc::now().timestamp(),
};
// 保存到数据库
db.save_skill(&skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
log::info!(
"Skill {} installed from ZIP, enabled for {:?}",
skill.name,
current_app
);
installed.push(skill);
}
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
Ok(installed)
}
/// 解压本地 ZIP 文件到临时目录
fn extract_local_zip(zip_path: &Path) -> Result<PathBuf> {
let file = fs::File::open(zip_path)
.with_context(|| format!("Failed to open ZIP file: {}", zip_path.display()))?;
let mut archive = zip::ZipArchive::new(file)
.with_context(|| format!("Failed to read ZIP file: {}", zip_path.display()))?;
if archive.is_empty() {
return Err(anyhow!(format_skill_error(
"EMPTY_ARCHIVE",
&[],
Some("checkZipContent"),
)));
}
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = match file.enclosed_name() {
Some(path) => path.to_owned(),
None => continue,
};
let outpath = temp_path.join(&file_path);
if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
fs::create_dir_all(parent)?;
}
let mut outfile = fs::File::create(&outpath)?;
std::io::copy(&mut file, &mut outfile)?;
}
}
Ok(temp_path)
}
/// 递归扫描目录查找包含 SKILL.md 的技能目录
fn scan_skills_in_dir(dir: &Path) -> Result<Vec<PathBuf>> {
let mut skill_dirs = Vec::new();
Self::scan_skills_recursive(dir, &mut skill_dirs)?;
Ok(skill_dirs)
}
/// 递归扫描辅助函数
fn scan_skills_recursive(current: &Path, results: &mut Vec<PathBuf>) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current.join("SKILL.md");
if skill_md.exists() {
results.push(current.to_path_buf());
// 找到后不再递归子目录(一个 skill 目录)
return Ok(());
}
// 递归子目录
if let Ok(entries) = fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// 跳过隐藏目录
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') {
continue;
}
Self::scan_skills_recursive(&path, results)?;
}
}
}
Ok(())
}
// ========== 仓库管理(保留原有逻辑)==========
/// 列出仓库
+40 -78
View File
@@ -12,7 +12,7 @@ use std::time::Instant;
use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
use crate::proxy::providers::{get_adapter, AuthInfo};
/// 健康状态枚举
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -303,18 +303,12 @@ impl StreamCheckService {
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// 根据 auth.strategy 构建认证 headers
let mut request_builder = client
// 严格按照 Claude CLI 请求格式设置 headers
let response = client
.post(&url)
.header("authorization", format!("Bearer {}", auth.api_key));
// 只有 Anthropic 官方策略才添加 x-api-key
if auth.strategy == AuthStrategy::Anthropic {
request_builder = request_builder.header("x-api-key", &auth.api_key);
}
// 严格按照 Claude CLI 请求格式设置其他 headers
let response = request_builder
// 认证 headers(双重认证)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key)
// Anthropic 必需 headers
.header("anthropic-version", "2023-06-01")
.header(
@@ -379,15 +373,11 @@ impl StreamCheckService {
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀),
// Responses 端点为 `/responses`。
//
// 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。
// 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。
let urls = if base.ends_with("/v1") {
vec![format!("{base}/responses")]
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
let url = if base.ends_with("/v1") {
format!("{base}/responses")
} else {
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
format!("{base}/v1/responses")
};
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
@@ -409,50 +399,40 @@ impl StreamCheckService {
body["reasoning"] = json!({ "effort": effort });
}
for (i, url) in urls.iter().enumerate() {
// 严格按照 Codex CLI 请求格式设置 headers
let response = client
.post(url)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header(
"user-agent",
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
)
.header("originator", "codex_cli_rs")
.timeout(timeout)
.json(&body)
.send()
.await
.map_err(Self::map_request_error)?;
// 严格按照 Codex CLI 请求格式设置 headers
let response = client
.post(&url)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header(
"user-agent",
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
)
.header("originator", "codex_cli_rs")
.timeout(timeout)
.json(&body)
.send()
.await
.map_err(Self::map_request_error)?;
let status = response.status().as_u16();
let status = response.status().as_u16();
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
// 回退策略:仅当首选 URL 返回 404 时尝试下一个
if i == 0 && status == 404 && urls.len() > 1 {
continue;
}
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
}
let mut stream = response.bytes_stream();
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => return Ok((status, actual_model)),
Err(e) => return Err(AppError::Message(format!("Stream read failed: {e}"))),
}
}
return Err(AppError::Message("No response data received".to_string()));
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
}
Err(AppError::Message(
"No valid Codex responses endpoint found".to_string(),
))
let mut stream = response.bytes_stream();
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("No response data received".to_string()))
}
}
/// Gemini 流式检查
@@ -709,22 +689,4 @@ mod tests {
#[cfg(target_arch = "x86_64")]
assert_eq!(arch_name, "x86_64");
}
#[test]
fn test_auth_strategy_imports() {
// 验证 AuthStrategy 枚举可以正常使用
let anthropic = AuthStrategy::Anthropic;
let claude_auth = AuthStrategy::ClaudeAuth;
let bearer = AuthStrategy::Bearer;
// 验证不同的策略是不相等的
assert_ne!(anthropic, claude_auth);
assert_ne!(anthropic, bearer);
assert_ne!(claude_auth, bearer);
// 验证相同策略是相等的
assert_eq!(anthropic, AuthStrategy::Anthropic);
assert_eq!(claude_auth, AuthStrategy::ClaudeAuth);
assert_eq!(bearer, AuthStrategy::Bearer);
}
}
+49 -65
View File
@@ -94,9 +94,6 @@ pub struct RequestLogDetail {
pub provider_name: Option<String>,
pub app_type: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_model: Option<String>,
pub cost_multiplier: String,
pub input_tokens: u32,
pub output_tokens: u32,
pub cache_read_tokens: u32,
@@ -143,7 +140,7 @@ impl Database {
};
let sql = format!(
"SELECT
"SELECT
COUNT(*) as total_requests,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
@@ -221,7 +218,7 @@ impl Database {
}
let sql = "
SELECT
SELECT
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
@@ -272,7 +269,7 @@ impl Database {
.single()
.unwrap_or_else(Local::now);
let date = bucket_start.to_rfc3339();
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
if let Some(mut stat) = map.remove(&i) {
stat.date = date;
@@ -298,7 +295,7 @@ impl Database {
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
let conn = lock_conn!(self.conn);
let sql = "SELECT
let sql = "SELECT
l.provider_id,
p.name as provider_name,
COUNT(*) as request_count,
@@ -346,7 +343,7 @@ impl Database {
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
let conn = lock_conn!(self.conn);
let sql = "SELECT
let sql = "SELECT
model,
COUNT(*) as request_count,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
@@ -427,7 +424,7 @@ impl Database {
// 获取总数
let count_sql = format!(
"SELECT COUNT(*) FROM proxy_request_logs l
"SELECT COUNT(*) FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{where_clause}"
);
@@ -443,7 +440,6 @@ impl Database {
let sql = format!(
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
@@ -464,26 +460,22 @@ impl Database {
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
request_model: row.get(5)?,
cost_multiplier: row
.get::<_, Option<String>>(6)?
.unwrap_or_else(|| "1".to_string()),
input_tokens: row.get::<_, i64>(7)? as u32,
output_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32,
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
input_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?,
total_cost_usd: row.get(15)?,
is_streaming: row.get::<_, i64>(16)? != 0,
latency_ms: row.get::<_, i64>(17)? as u64,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
input_tokens: row.get::<_, i64>(5)? as u32,
output_tokens: row.get::<_, i64>(6)? as u32,
cache_read_tokens: row.get::<_, i64>(7)? as u32,
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
input_cost_usd: row.get(9)?,
output_cost_usd: row.get(10)?,
cache_read_cost_usd: row.get(11)?,
cache_creation_cost_usd: row.get(12)?,
total_cost_usd: row.get(13)?,
is_streaming: row.get::<_, i64>(14)? != 0,
latency_ms: row.get::<_, i64>(15)? as u64,
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
status_code: row.get::<_, i64>(18)? as u16,
error_message: row.get(19)?,
created_at: row.get(20)?,
})
})?;
@@ -519,7 +511,6 @@ impl Database {
let result = conn.query_row(
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
is_streaming, latency_ms, first_token_ms, duration_ms,
@@ -535,24 +526,22 @@ impl Database {
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
request_model: row.get(5)?,
cost_multiplier: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "1".to_string()),
input_tokens: row.get::<_, i64>(7)? as u32,
output_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32,
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
input_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?,
total_cost_usd: row.get(15)?,
is_streaming: row.get::<_, i64>(16)? != 0,
latency_ms: row.get::<_, i64>(17)? as u64,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
input_tokens: row.get::<_, i64>(5)? as u32,
output_tokens: row.get::<_, i64>(6)? as u32,
cache_read_tokens: row.get::<_, i64>(7)? as u32,
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
input_cost_usd: row.get(9)?,
output_cost_usd: row.get(10)?,
cache_read_cost_usd: row.get(11)?,
cache_creation_cost_usd: row.get(12)?,
total_cost_usd: row.get(13)?,
is_streaming: row.get::<_, i64>(14)? != 0,
latency_ms: row.get::<_, i64>(15)? as u64,
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
status_code: row.get::<_, i64>(18)? as u16,
error_message: row.get(19)?,
created_at: row.get(20)?,
})
},
);
@@ -702,26 +691,21 @@ impl Database {
)?;
let million = rust_decimal::Decimal::from(1_000_000u64);
// 与 CostCalculator::calculate 保持一致的计算逻辑:
// 1. input_cost 需要扣除 cache_read_tokens(避免缓存部分被重复计费)
// 2. 各项成本是基础成本(不含倍率)
// 3. 倍率只作用于最终总价
let billable_input_tokens =
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64);
let input_cost =
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
let output_cost =
rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output / million;
let input_cost = rust_decimal::Decimal::from(log.input_tokens as u64) * pricing.input
/ million
* multiplier;
let output_cost = rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output
/ million
* multiplier;
let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
* pricing.cache_read
/ million;
/ million
* multiplier;
let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)
* pricing.cache_creation
/ million;
// 总成本 = 基础成本之和 × 倍率
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
let total_cost = base_total * multiplier;
/ million
* multiplier;
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
log.input_cost_usd = format!("{input_cost:.6}");
log.output_cost_usd = format!("{output_cost:.6}");
-60
View File
@@ -1,60 +0,0 @@
pub mod providers;
pub mod terminal;
use serde::Serialize;
use std::path::Path;
use providers::{claude, codex};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionMeta {
pub provider_id: String,
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_active_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resume_command: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionMessage {
pub role: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts: Option<i64>,
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let mut sessions = Vec::new();
sessions.extend(codex::scan_sessions());
sessions.extend(claude::scan_sessions());
sessions.sort_by(|a, b| {
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0);
b_ts.cmp(&a_ts)
});
sessions
}
pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<SessionMessage>, String> {
let path = Path::new(source_path);
match provider_id {
"codex" => codex::load_messages(path),
"claude" => claude::load_messages(path),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
@@ -1,186 +0,0 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::config::get_claude_config_dir;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
const PROVIDER_ID: &str = "claude";
pub fn scan_sessions() -> Vec<SessionMeta> {
let root = get_claude_config_dir().join("projects");
let mut files = Vec::new();
collect_jsonl_files(&root, &mut files);
let mut sessions = Vec::new();
for path in files {
if let Some(meta) = parse_session(&path) {
sessions.push(meta);
}
}
sessions
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
continue;
}
let message = match value.get("message") {
Some(message) => message,
None => continue,
};
let role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let content = message.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
}
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
messages.push(SessionMessage { role, content, ts });
}
Ok(messages)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
if is_agent_session(path) {
return None;
}
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if session_id.is_none() {
session_id = value
.get("sessionId")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if project_dir.is_none() {
project_dir = value
.get("cwd")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
if created_at.is_none() {
created_at = Some(ts);
}
last_active_at = Some(ts);
}
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
continue;
}
let message = match value.get("message") {
Some(message) => message,
None => continue,
};
let text = message.get("content").map(extract_text).unwrap_or_default();
if text.trim().is_empty() {
continue;
}
summary = Some(text);
}
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
let session_id = session_id?;
let title = project_dir
.as_deref()
.and_then(path_basename)
.map(|value| value.to_string());
let summary = summary.map(|text| truncate_summary(&text, 160));
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: session_id.clone(),
title,
summary,
project_dir,
created_at,
last_active_at,
source_path: Some(path.to_string_lossy().to_string()),
resume_command: Some(format!("claude --resume {session_id}")),
})
}
fn is_agent_session(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with("agent-"))
.unwrap_or(false)
}
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
path.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.to_string())
}
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
if !root.exists() {
return;
}
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_jsonl_files(&path, files);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
files.push(path);
}
}
}
@@ -1,193 +0,0 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use regex::Regex;
use serde_json::Value;
use crate::codex_config::get_codex_config_dir;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
const PROVIDER_ID: &str = "codex";
pub fn scan_sessions() -> Vec<SessionMeta> {
let root = get_codex_config_dir().join("sessions");
let mut files = Vec::new();
collect_jsonl_files(&root, &mut files);
let mut sessions = Vec::new();
for path in files {
if let Some(meta) = parse_session(&path) {
sessions.push(meta);
}
}
sessions
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if value.get("type").and_then(Value::as_str) != Some("response_item") {
continue;
}
let payload = match value.get("payload") {
Some(payload) => payload,
None => continue,
};
if payload.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
let role = payload
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let content = payload.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
}
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
messages.push(SessionMessage { role, content, ts });
}
Ok(messages)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
if created_at.is_none() {
created_at = Some(ts);
}
last_active_at = Some(ts);
}
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
if let Some(payload) = value.get("payload") {
if session_id.is_none() {
session_id = payload
.get("id")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if project_dir.is_none() {
project_dir = payload
.get("cwd")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if let Some(ts) = payload.get("timestamp").and_then(parse_timestamp_to_ms) {
created_at.get_or_insert(ts);
}
}
continue;
}
if value.get("type").and_then(Value::as_str) != Some("response_item") {
continue;
}
let payload = match value.get("payload") {
Some(payload) => payload,
None => continue,
};
if payload.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
let text = payload.get("content").map(extract_text).unwrap_or_default();
if text.trim().is_empty() {
continue;
}
summary = Some(text);
}
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
let session_id = session_id?;
let title = project_dir
.as_deref()
.and_then(path_basename)
.map(|value| value.to_string());
let summary = summary.map(|text| truncate_summary(&text, 160));
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: session_id.clone(),
title,
summary,
project_dir,
created_at,
last_active_at,
source_path: Some(path.to_string_lossy().to_string()),
resume_command: Some(format!("codex resume {session_id}")),
})
}
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
let file_name = path.file_name()?.to_string_lossy();
let re =
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
.ok()?;
re.find(&file_name).map(|mat| mat.as_str().to_string())
}
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
if !root.exists() {
return;
}
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_jsonl_files(&path, files);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
files.push(path);
}
}
}
@@ -1,3 +0,0 @@
pub mod claude;
pub mod codex;
mod utils;
@@ -1,77 +0,0 @@
use chrono::{DateTime, FixedOffset};
use serde_json::Value;
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
let raw = value.as_str()?;
DateTime::parse_from_rfc3339(raw)
.ok()
.map(|dt: DateTime<FixedOffset>| dt.timestamp_millis())
}
pub fn extract_text(content: &Value) -> String {
match content {
Value::String(text) => text.to_string(),
Value::Array(items) => items
.iter()
.filter_map(extract_text_from_item)
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n"),
Value::Object(map) => map
.get("text")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
_ => String::new(),
}
}
fn extract_text_from_item(item: &Value) -> Option<String> {
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
if let Some(text) = item.get("input_text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
if let Some(text) = item.get("output_text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
if let Some(content) = item.get("content") {
let text = extract_text(content);
if !text.is_empty() {
return Some(text);
}
}
None
}
pub fn truncate_summary(text: &str, max_chars: usize) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
return String::new();
}
if trimmed.chars().count() <= max_chars {
return trimmed.to_string();
}
let mut result = trimmed.chars().take(max_chars).collect::<String>();
result.push_str("...");
result
}
pub fn path_basename(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let normalized = trimmed.trim_end_matches(['/', '\\']);
let last = normalized
.split(['/', '\\'])
.next_back()
.filter(|segment| !segment.is_empty())?;
Some(last.to_string())
}
@@ -1,257 +0,0 @@
use std::process::Command;
pub fn launch_terminal(
target: &str,
command: &str,
cwd: Option<&str>,
custom_config: Option<&str>,
) -> Result<(), String> {
if command.trim().is_empty() {
return Err("Resume command is empty".to_string());
}
if !cfg!(target_os = "macos") {
return Err("Terminal resume is only supported on macOS".to_string());
}
match target {
"terminal" => launch_macos_terminal(command, cwd),
"iTerm" | "iterm" => launch_iterm(command, cwd),
"ghostty" => launch_ghostty(command, cwd),
"kitty" => launch_kitty(command, cwd),
"wezterm" => launch_wezterm(command, cwd),
"alacritty" => launch_alacritty(command, cwd),
"custom" => launch_custom(command, cwd, custom_config),
_ => Err(format!("Unsupported terminal target: {target}")),
}
}
fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> {
let full_command = build_shell_command(command, cwd);
let escaped = escape_osascript(&full_command);
let script = format!(
r#"tell application "Terminal"
activate
do script "{escaped}"
end tell"#
);
let status = Command::new("osascript")
.arg("-e")
.arg(script)
.status()
.map_err(|e| format!("Failed to launch Terminal: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Terminal command execution failed".to_string())
}
}
fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
let full_command = build_shell_command(command, cwd);
let escaped = escape_osascript(&full_command);
// iTerm2 AppleScript to create a new window and execute command
let script = format!(
r#"tell application "iTerm"
activate
create window with default profile
tell current session of current window
write text "{escaped}"
end tell
end tell"#
);
let status = Command::new("osascript")
.arg("-e")
.arg(script)
.status()
.map_err(|e| format!("Failed to launch iTerm: {e}"))?;
if status.success() {
Ok(())
} else {
Err("iTerm command execution failed".to_string())
}
}
fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Ghostty usage: open -na Ghostty --args +work-dir=... -e shell -c command
// Using `open` to launch.
let mut args = vec!["-na", "Ghostty", "--args"];
// Ghostty uses --working-directory for working directory (or +work-dir, but --working-directory is standard in newer versions/compat)
// Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones.
// Documentation says --working-directory is supported in CLI.
let work_dir_arg = if let Some(dir) = cwd {
format!("--working-directory={dir}")
} else {
"".to_string()
};
if !work_dir_arg.is_empty() {
args.push(&work_dir_arg);
}
// Command execution
args.push("-e");
// We pass the command and its arguments separately.
// The previous issue was passing the entire "cmd args" string as a single argument to -e,
// which led Ghostty to look for a binary named "cmd args".
// Splitting by whitespace allows Ghostty to see ["cmd", "args"].
// Note: This assumes simple commands without quoted arguments containing spaces.
let full_command = build_shell_command(command, None);
for part in full_command.split_whitespace() {
args.push(part);
}
let status = Command::new("open")
.args(&args)
.status()
.map_err(|e| format!("Failed to launch Ghostty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Ghostty. Make sure it is installed.".to_string())
}
}
fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
let full_command = build_shell_command(command, cwd);
// 获取用户默认 shell
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let status = Command::new("open")
.arg("-na")
.arg("kitty")
.arg("--args")
.arg("-e")
.arg(&shell)
.arg("-l")
.arg("-c")
.arg(&full_command)
.status()
.map_err(|e| format!("Failed to launch Kitty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Kitty. Make sure it is installed.".to_string())
}
}
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
// wezterm start --cwd ... -- command
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
let full_command = build_shell_command(command, None);
let mut args = vec!["-na", "WezTerm", "--args", "start"];
if let Some(dir) = cwd {
args.push("--cwd");
args.push(dir);
}
// Invoke shell to run the command string (to handle pipes, etc)
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
args.push("--");
args.push(&shell);
args.push("-c");
args.push(&full_command);
let status = Command::new("open")
.args(&args)
.status()
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch WezTerm.".to_string())
}
}
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
let full_command = build_shell_command(command, None);
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let mut args = vec!["-na", "Alacritty", "--args"];
if let Some(dir) = cwd {
args.push("--working-directory");
args.push(dir);
}
args.push("-e");
args.push(&shell);
args.push("-c");
args.push(&full_command);
let status = Command::new("open")
.args(&args)
.status()
.map_err(|e| format!("Failed to launch Alacritty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Alacritty.".to_string())
}
}
fn launch_custom(
command: &str,
cwd: Option<&str>,
custom_config: Option<&str>,
) -> Result<(), String> {
let template = custom_config.ok_or("No custom terminal config provided")?;
if template.trim().is_empty() {
return Err("Custom terminal command template is empty".to_string());
}
let cmd_str = command;
let dir_str = cwd.unwrap_or(".");
let final_cmd_line = template
.replace("{command}", cmd_str)
.replace("{cwd}", dir_str);
// Execute via sh -c
let status = Command::new("sh")
.arg("-c")
.arg(&final_cmd_line)
.status()
.map_err(|e| format!("Failed to execute custom terminal launcher: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Custom terminal execution returned error code".to_string())
}
}
fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
match cwd {
Some(dir) if !dir.trim().is_empty() => {
format!("cd {} && {}", shell_escape(dir), command)
}
_ => command.to_string(),
}
}
fn shell_escape(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn escape_osascript(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
+1 -32
View File
@@ -79,9 +79,6 @@ pub struct AppSettings {
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
#[serde(default)]
pub silent_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
@@ -117,14 +114,6 @@ pub struct AppSettings {
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
#[serde(default)]
pub skill_sync_method: SyncMethod,
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_terminal: Option<String>,
}
fn default_show_in_tray() -> bool {
@@ -143,7 +132,6 @@ impl Default for AppSettings {
enable_claude_plugin_integration: false,
skip_claude_onboarding: false,
launch_on_startup: false,
silent_startup: false,
language: None,
visible_apps: None,
claude_config_dir: None,
@@ -155,7 +143,6 @@ impl Default for AppSettings {
current_provider_gemini: None,
current_provider_opencode: None,
skill_sync_method: SyncMethod::default(),
preferred_terminal: None,
}
}
}
@@ -163,11 +150,7 @@ impl Default for AppSettings {
impl AppSettings {
fn settings_path() -> Option<PathBuf> {
// settings.json 保留用于旧版本迁移和无数据库场景
Some(
crate::config::get_home_dir()
.join(".cc-switch")
.join("settings.json"),
)
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
}
fn normalize_paths(&mut self) {
@@ -419,17 +402,3 @@ pub fn get_skill_sync_method() -> SyncMethod {
})
.skill_sync_method
}
// ===== 终端设置管理函数 =====
/// 获取首选终端应用
pub fn get_preferred_terminal() -> Option<String> {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.preferred_terminal
.clone()
}
+5 -8
View File
@@ -63,7 +63,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
prefix: "claude_",
header_id: "claude_header",
empty_id: "claude_empty",
header_label: "Claude",
header_label: "─── Claude ───",
log_name: "Claude",
},
TrayAppSection {
@@ -71,7 +71,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
prefix: "codex_",
header_id: "codex_header",
empty_id: "codex_empty",
header_label: "Codex",
header_label: "─── Codex ───",
log_name: "Codex",
},
TrayAppSection {
@@ -79,7 +79,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
prefix: "gemini_",
header_id: "gemini_header",
empty_id: "gemini_empty",
header_label: "Gemini",
header_label: "─── Gemini ───",
log_name: "Gemini",
},
];
@@ -391,16 +391,13 @@ pub fn create_tray_menu(
&tray_texts,
app_state,
)?;
// 在每个 section 后添加分隔符
menu_builder = menu_builder.separator();
}
// 退出菜单(分隔符已在上面的 section 循环中添加)
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
menu_builder = menu_builder.item(&quit_item);
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
+1 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.10.3",
"version": "3.10.2",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -19,7 +19,6 @@
"height": 650,
"minWidth": 900,
"minHeight": 600,
"visible": false,
"resizable": true,
"fullscreen": false,
"center": true
-1
View File
@@ -6,7 +6,6 @@
"label": "main",
"title": "CC Switch",
"titleBarStyle": "Visible",
"visible": false,
"minWidth": 900,
"minHeight": 600
}
+4 -10
View File
@@ -971,18 +971,12 @@ fn export_sql_returns_error_for_invalid_path() {
let state = create_test_state().expect("create test state");
// Try to export to an invalid path (nonexistent parent or invalid name on Windows)
let invalid_parent = if cfg!(windows) {
std::env::temp_dir().join("cc-switch-test-invalid<>dir")
} else {
PathBuf::from("/nonexistent/directory")
};
let invalid_path = invalid_parent.join("export.sql");
// Try to export to an invalid path (parent directory doesn't exist)
let invalid_path = PathBuf::from("/nonexistent/directory/export.sql");
let err = state
.db
.export_sql(&invalid_path)
.expect_err("export to invalid path should fail");
let invalid_prefix = invalid_parent.to_string_lossy();
// The error can be either IoContext or Io depending on where it fails
match err {
@@ -994,8 +988,8 @@ fn export_sql_returns_error_for_invalid_path() {
}
AppError::Io { path, .. } => {
assert!(
path.starts_with(invalid_prefix.as_ref()),
"expected error for {invalid_parent:?}, got: {path:?}"
path.starts_with("/nonexistent"),
"expected error for /nonexistent path, got: {path:?}"
);
}
other => panic!("expected IoContext or Io error, got {other:?}"),
-78
View File
@@ -1,78 +0,0 @@
use cc_switch_lib::{
get_default_cost_multiplier_test_hook, get_pricing_model_source_test_hook,
set_default_cost_multiplier_test_hook, set_pricing_model_source_test_hook, AppError,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn default_cost_multiplier_commands_round_trip() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
let default = get_default_cost_multiplier_test_hook(&state, "claude")
.await
.expect("read default multiplier");
assert_eq!(default, "1");
set_default_cost_multiplier_test_hook(&state, "claude", "1.5")
.await
.expect("set multiplier");
let updated = get_default_cost_multiplier_test_hook(&state, "claude")
.await
.expect("read updated multiplier");
assert_eq!(updated, "1.5");
let err = set_default_cost_multiplier_test_hook(&state, "claude", "not-a-number")
.await
.expect_err("invalid multiplier should error");
// 错误已改为 Localized 类型(支持 i18n
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "error.invalidMultiplier");
}
other => panic!("expected localized error, got {other:?}"),
}
}
// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn pricing_model_source_commands_round_trip() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
let default = get_pricing_model_source_test_hook(&state, "claude")
.await
.expect("read default pricing model source");
assert_eq!(default, "response");
set_pricing_model_source_test_hook(&state, "claude", "request")
.await
.expect("set pricing model source");
let updated = get_pricing_model_source_test_hook(&state, "claude")
.await
.expect("read updated pricing model source");
assert_eq!(updated, "request");
let err = set_pricing_model_source_test_hook(&state, "claude", "invalid")
.await
.expect_err("invalid pricing model source should error");
// 错误已改为 Localized 类型(支持 i18n
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "error.invalidPricingMode");
}
other => panic!("expected localized error, got {other:?}"),
}
}
-3
View File
@@ -14,9 +14,6 @@ pub fn ensure_test_home() -> &'static Path {
let _ = std::fs::remove_dir_all(&base);
}
std::fs::create_dir_all(&base).expect("create test home");
// Windows 上 `dirs::home_dir()` 不受 HOME/USERPROFILE 影响(走 Known Folder API),
// 用 CC_SWITCH_TEST_HOME 显式覆盖,以确保测试不会污染真实用户目录。
std::env::set_var("CC_SWITCH_TEST_HOME", &base);
std::env::set_var("HOME", &base);
#[cfg(windows)]
std::env::set_var("USERPROFILE", &base);
+69 -97
View File
@@ -8,14 +8,13 @@ import {
Plus,
Settings,
ArrowLeft,
// Bot, // TODO: Agents 功能开发中,暂时不需要
Book,
Wrench,
RefreshCw,
History,
BarChart2,
Download,
FolderArchive,
Search,
Download,
BarChart2,
} from "lucide-react";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
@@ -54,8 +53,6 @@ import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal";
import { McpIcon } from "@/components/BrandIcons";
import { Button } from "@/components/ui/button";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { useDisableCurrentOmo } from "@/lib/query/omo";
type View =
| "providers"
@@ -65,58 +62,23 @@ type View =
| "skillsDiscovery"
| "mcp"
| "agents"
| "universal"
| "sessions";
| "universal";
// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
const STORAGE_KEY = "cc-switch-last-app";
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
const getInitialApp = (): AppId => {
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
if (saved && VALID_APPS.includes(saved)) {
return saved;
}
return "claude";
};
const VIEW_STORAGE_KEY = "cc-switch-last-view";
const VALID_VIEWS: View[] = [
"providers",
"settings",
"prompts",
"skills",
"skillsDiscovery",
"mcp",
"agents",
"universal",
"sessions",
];
const getInitialView = (): View => {
const saved = localStorage.getItem(VIEW_STORAGE_KEY) as View | null;
if (saved && VALID_VIEWS.includes(saved)) {
return saved;
}
return "providers";
};
function App() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeApp, setActiveApp] = useState<AppId>(getInitialApp);
const [currentView, setCurrentView] = useState<View>(getInitialView);
const [activeApp, setActiveApp] = useState<AppId>("claude");
const [currentView, setCurrentView] = useState<View>("providers");
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
const [isAddOpen, setIsAddOpen] = useState(false);
useEffect(() => {
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
}, [currentView]);
// Get settings for visibleApps
const { data: settingsData } = useSettingsQuery();
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
claude: true,
@@ -125,6 +87,7 @@ function App() {
opencode: true,
};
// Get first visible app for fallback
const getFirstVisibleApp = (): AppId => {
if (visibleApps.claude) return "claude";
if (visibleApps.codex) return "codex";
@@ -133,6 +96,7 @@ function App() {
return "claude"; // fallback
};
// If current active app is hidden, switch to first visible app
useEffect(() => {
if (!visibleApps[activeApp]) {
setActiveApp(getFirstVisibleApp());
@@ -141,6 +105,7 @@ function App() {
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
// Confirm action state: 'remove' = remove from live config, 'delete' = delete from database
const [confirmAction, setConfirmAction] = useState<{
provider: Provider;
action: "remove" | "delete";
@@ -148,6 +113,7 @@ function App() {
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
@@ -158,12 +124,15 @@ function App() {
const addActionButtonClass =
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
// 获取代理服务状态
const {
isRunning: isProxyRunning,
takeoverStatus,
status: proxyStatus,
} = useProxyStatus();
// 当前应用的代理是否开启
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
const activeProviderId = useMemo(() => {
const target = proxyStatus?.active_targets?.find(
(t) => t.app_type === activeApp,
@@ -171,6 +140,7 @@ function App() {
return target?.provider_id;
}, [proxyStatus?.active_targets, activeApp]);
// 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
isProxyRunning,
});
@@ -178,6 +148,7 @@ function App() {
const currentProviderId = data?.currentProviderId ?? "";
const hasSkillsSupport = true;
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
addProvider,
updateProvider,
@@ -186,23 +157,7 @@ function App() {
saveUsageScript,
} = useProviderActions(activeApp);
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
disableOmoMutation.mutate(undefined, {
onSuccess: () => {
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
},
onError: (error: Error) => {
toast.error(
t("omo.disableFailed", {
defaultValue: "停用 OMO 失败: {{error}}",
error: extractErrorMessage(error),
}),
);
},
});
};
// 监听来自托盘菜单的切换事件
useEffect(() => {
let unsubscribe: (() => void) | undefined;
@@ -226,6 +181,7 @@ function App() {
};
}, [activeApp, refetch]);
// 监听统一供应商同步事件,刷新所有应用的供应商列表
useEffect(() => {
let unsubscribe: (() => void) | undefined;
@@ -233,7 +189,10 @@ function App() {
try {
const { listen } = await import("@tauri-apps/api/event");
unsubscribe = await listen("universal-provider-synced", async () => {
// 统一供应商同步后刷新所有应用的供应商列表
// 使用 invalidateQueries 使所有 providers 查询失效
await queryClient.invalidateQueries({ queryKey: ["providers"] });
// 同时更新托盘菜单
try {
await providersApi.updateTrayMenu();
} catch (error) {
@@ -254,6 +213,7 @@ function App() {
};
}, [queryClient]);
// 应用启动时检测所有应用的环境变量冲突
useEffect(() => {
const checkEnvOnStartup = async () => {
try {
@@ -278,6 +238,7 @@ function App() {
checkEnvOnStartup();
}, []);
// 应用启动时检查是否刚完成了配置迁移
useEffect(() => {
const checkMigration = async () => {
try {
@@ -296,6 +257,7 @@ function App() {
checkMigration();
}, [t]);
// 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT)
useEffect(() => {
const checkSkillsMigration = async () => {
try {
@@ -324,12 +286,14 @@ function App() {
checkSkillsMigration();
}, [t, queryClient]);
// 切换应用时检测当前应用的环境变量冲突
useEffect(() => {
const checkEnvOnSwitch = async () => {
try {
const conflicts = await checkEnvConflicts(activeApp);
if (conflicts.length > 0) {
// 合并新检测到的冲突
setEnvConflicts((prev) => {
const existingKeys = new Set(
prev.map((c) => `${c.varName}:${c.sourcePath}`),
@@ -355,6 +319,7 @@ function App() {
checkEnvOnSwitch();
}, [activeApp]);
// 全局键盘快捷键
const currentViewRef = useRef(currentView);
useEffect(() => {
@@ -363,14 +328,17 @@ function App() {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Cmd/Ctrl + , 打开设置
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setCurrentView("settings");
return;
}
// ESC 键返回
if (event.key !== "Escape" || event.defaultPrevented) return;
// 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理
if (document.body.style.overflow === "hidden") return;
const view = currentViewRef.current;
@@ -388,6 +356,7 @@ function App() {
};
}, []);
// 打开网站链接
const handleOpenWebsite = async (url: string) => {
try {
await settingsApi.openExternal(url);
@@ -401,17 +370,22 @@ function App() {
}
};
// 编辑供应商
const handleEditProvider = async (provider: Provider) => {
await updateProvider(provider);
setEditingProvider(null);
};
// 确认删除/移除供应商
const handleConfirmAction = async () => {
if (!confirmAction) return;
const { provider, action } = confirmAction;
if (action === "remove") {
// Remove from live config only (for additive mode apps like OpenCode)
// Does NOT delete from database - provider remains in the list
await providersApi.removeFromLiveConfig(provider.id, activeApp);
// Invalidate queries to refresh the isInConfig state
await queryClient.invalidateQueries({
queryKey: ["opencodeLiveProviderIds"],
});
@@ -422,11 +396,13 @@ function App() {
{ closeButton: true },
);
} else {
// Delete from database
await deleteProvider(provider.id);
}
setConfirmAction(null);
};
// Generate a unique provider key for OpenCode duplication
const generateUniqueOpencodeKey = (
originalKey: string,
existingKeys: string[],
@@ -437,6 +413,7 @@ function App() {
return baseKey;
}
// If -copy already exists, try -copy-2, -copy-3, ...
let counter = 2;
while (existingKeys.includes(`${baseKey}-${counter}`)) {
counter++;
@@ -444,7 +421,9 @@ function App() {
return `${baseKey}-${counter}`;
};
// 复制供应商
const handleDuplicateProvider = async (provider: Provider) => {
// 1️⃣ 计算新的 sortIndex:如果原供应商有 sortIndex,则复制它
const newSortIndex =
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
@@ -463,6 +442,7 @@ function App() {
iconColor: provider.iconColor,
};
// OpenCode: generate unique provider key (used as ID)
if (activeApp === "opencode") {
const existingKeys = Object.keys(providers);
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
@@ -471,6 +451,7 @@ function App() {
);
}
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
if (provider.sortIndex !== undefined) {
const updates = Object.values(providers)
.filter(
@@ -484,6 +465,7 @@ function App() {
sortIndex: p.sortIndex! + 1,
}));
// 先更新现有供应商的 sortIndex,为新供应商腾出位置
if (updates.length > 0) {
try {
await providersApi.updateSortOrder(updates, activeApp);
@@ -499,9 +481,11 @@ function App() {
}
}
// 3️⃣ 添加复制的供应商
await addProvider(duplicatedProvider);
};
// 打开提供商终端
const handleOpenTerminal = async (provider: Provider) => {
try {
await providersApi.openTerminal(provider.id, activeApp);
@@ -521,8 +505,10 @@ function App() {
}
};
// 导入配置成功后刷新
const handleImportSuccess = async () => {
try {
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
await queryClient.invalidateQueries({
queryKey: ["providers"],
refetchType: "all",
@@ -594,12 +580,10 @@ function App() {
<UniversalProviderPanel />
</div>
);
case "sessions":
return <SessionManagerPage />;
default:
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<AnimatePresence mode="wait">
<motion.div
@@ -621,9 +605,7 @@ function App() {
}
activeProviderId={activeProviderId}
onSwitch={switchProvider}
onEdit={(provider) => {
setEditingProvider(provider);
}}
onEdit={setEditingProvider}
onDelete={(provider) =>
setConfirmAction({ provider, action: "delete" })
}
@@ -633,9 +615,6 @@ function App() {
setConfirmAction({ provider, action: "remove" })
: undefined
}
onDisableOmo={
activeApp === "opencode" ? handleDisableOmo : undefined
}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
@@ -656,7 +635,6 @@ function App() {
<AnimatePresence mode="wait">
<motion.div
key={currentView}
className="flex-1 min-h-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -673,11 +651,13 @@ function App() {
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
>
{/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
<div
className="fixed top-0 left-0 right-0 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/>
{/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
@@ -686,6 +666,7 @@ function App() {
sessionStorage.setItem("env_banner_dismissed", "true");
}}
onDeleted={async () => {
// 删除后重新检测
try {
const allConflicts = await checkAllEnvConflicts();
const flatConflicts = Object.values(allConflicts).flat();
@@ -751,7 +732,6 @@ function App() {
t("universalProvider.title", {
defaultValue: "统一供应商",
})}
{currentView === "sessions" && t("sessionManager.title")}
</h1>
</div>
) : (
@@ -797,7 +777,7 @@ function App() {
setSettingsDefaultTab("usage");
setCurrentView("settings");
}}
title={t("usage.title", {
title={t("settings.usage.title", {
defaultValue: "使用统计",
})}
className="hover:bg-black/5 dark:hover:bg-white/5"
@@ -848,17 +828,6 @@ function App() {
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openInstallFromZip()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<FolderArchive className="w-4 h-4 mr-2" />
{t("skills.installFromZip.button")}
</Button>
<Button
variant="ghost"
size="sm"
@@ -945,6 +914,18 @@ function App() {
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="w-4 h-4" />
</Button>
)} */}
<Button
variant="ghost"
size="sm"
@@ -954,15 +935,6 @@ function App() {
>
<Book className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("sessionManager.title")}
>
<History className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -987,8 +959,8 @@ function App() {
</div>
</header>
<main className="flex-1 min-h-0 flex flex-col animate-fade-in">
{renderContent()}
<main className="flex-1 pb-12 animate-fade-in ">
<div className="pb-12">{renderContent()}</div>
</main>
<AddProviderDialog
-2
View File
@@ -10,7 +10,6 @@ interface AppSwitcherProps {
}
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
const STORAGE_KEY = "cc-switch-last-app";
export function AppSwitcher({
activeApp,
@@ -20,7 +19,6 @@ export function AppSwitcher({
}: AppSwitcherProps) {
const handleSwitch = (app: AppId) => {
if (app === activeApp) return;
localStorage.setItem(STORAGE_KEY, app);
onSwitch(app);
};
const iconSize = 20;
-34
View File
@@ -1,34 +0,0 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import type { AppId } from "@/lib/api/types";
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
interface AppCountBarProps {
totalLabel: string;
counts: Record<AppId, number>;
}
export const AppCountBar: React.FC<AppCountBarProps> = ({
totalLabel,
counts,
}) => {
return (
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
<Badge variant="outline" className="bg-background/50 h-7 px-3">
{totalLabel}
</Badge>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
{APP_IDS.map((app) => (
<Badge
key={app}
variant="secondary"
className={APP_ICON_MAP[app].badgeClass}
>
<span className="opacity-75">{APP_ICON_MAP[app].label}:</span>
<span className="font-bold ml-1">{counts[app]}</span>
</Badge>
))}
</div>
</div>
);
};
-48
View File
@@ -1,48 +0,0 @@
import React from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { AppId } from "@/lib/api/types";
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
interface AppToggleGroupProps {
apps: Record<AppId, boolean>;
onToggle: (app: AppId, enabled: boolean) => void;
}
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
apps,
onToggle,
}) => {
return (
<div className="flex items-center gap-1.5 flex-shrink-0">
{APP_IDS.map((app) => {
const { label, icon, activeClass } = APP_ICON_MAP[app];
const enabled = apps[app];
return (
<Tooltip key={app}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onToggle(app, !enabled)}
className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${
enabled ? activeClass : "opacity-35 hover:opacity-70"
}`}
>
{icon}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>
{label}
{enabled ? " ✓" : ""}
</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
);
};
-21
View File
@@ -1,21 +0,0 @@
import React from "react";
interface ListItemRowProps {
isLast?: boolean;
children: React.ReactNode;
}
export const ListItemRow: React.FC<ListItemRowProps> = ({
isLast,
children,
}) => {
return (
<div
className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors ${
!isLast ? "border-b border-border-default" : ""
}`}
>
{children}
</div>
);
};
-190
View File
@@ -1,190 +0,0 @@
import { SVGProps } from "react";
export function ITermIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>iTerm2</title>
<path d="M24 5.359v13.282A5.36 5.36 0 0 1 18.641 24H5.359A5.36 5.36 0 0 1 0 18.641V5.359A5.36 5.36 0 0 1 5.359 0h13.282A5.36 5.36 0 0 1 24 5.359m-.932-.233A4.196 4.196 0 0 0 18.874.932H5.126A4.196 4.196 0 0 0 .932 5.126v13.748a4.196 4.196 0 0 0 4.194 4.194h13.748a4.196 4.196 0 0 0 4.194-4.194zm-.816.233v13.282a3.613 3.613 0 0 1-3.611 3.611H5.359a3.613 3.613 0 0 1-3.611-3.611V5.359a3.613 3.613 0 0 1 3.611-3.611h13.282a3.613 3.613 0 0 1 3.611 3.611M8.854 4.194v6.495h.962V4.194zM5.483 9.493v1.085h.597V9.48q.283-.037.508-.133.373-.165.575-.448.208-.284.208-.649a.9.9 0 0 0-.171-.568 1.4 1.4 0 0 0-.426-.388 3 3 0 0 0-.544-.261 32 32 0 0 0-.545-.209 1.8 1.8 0 0 1-.426-.216q-.164-.12-.164-.284 0-.223.179-.351.18-.126.485-.127.344 0 .575.105.239.105.5.298l.433-.5a2.3 2.3 0 0 0-.605-.433 1.6 1.6 0 0 0-.582-.159v-.968h-.597v.978a2 2 0 0 0-.477.127 1.2 1.2 0 0 0-.545.411q-.194.268-.194.634 0 .335.164.56.164.224.418.38a4 4 0 0 0 .552.262q.291.104.545.209.261.104.425.238a.39.39 0 0 1 .165.321q0 .225-.187.359-.18.134-.537.134-.381 0-.717-.134a4.4 4.4 0 0 1-.649-.351l-.388.589q.209.173.477.306.276.135.575.217.191.046.373.064" />
</svg>
);
}
export function AlacrittyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Alacritty</title>
<path d="m10.065 0-8.57 21.269h3.595l6.91-16.244 6.91 16.244h3.594l-8.57-21.269zm1.935 9.935c-0.76666 1.8547-1.5334 3.7094-2.298 5.565 1.475 4.54 1.475 4.54 2.298 8.5 0.823-3.96 0.823-3.96 2.297-8.5-0.76637-1.8547-1.5315-3.7099-2.297-5.565z" />
</svg>
);
}
export function WezTermIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>WezTerm</title>
<path d="M3.27 8.524c0-.623.62-1.007 2.123-1.007l-.5 2.757c-.931-.623-1.624-1.199-1.624-1.75zm4.008 6.807c0 .647-.644 1.079-2.123 1.15l.524-2.924c.931.624 1.6 1.175 1.6 1.774zm-2.625 5.992.454-2.708c3.603-.336 5.01-1.798 5.01-3.404 0-1.653-2.004-2.948-3.841-4.074l.668-3.548c.764.072 1.67.216 2.744.432l.31-2.469c-.81-.12-1.575-.168-2.29-.216L8.257 2.7l-2.363-.024-.453 2.684C1.838 5.648.43 7.158.43 8.764c0 1.63 2.004 2.876 3.841 3.954l-.668 3.716c-.859-.048-1.908-.192-3.125-.408L0 18.495c1.026.12 1.98.192 2.84.216l-.525 2.588zm15.553-1.894h2.673c.334-2.804.81-8.46 1.121-14.86h-2.553c-.071 1.51-.334 10.498-.43 11.241h-.071c-.644-2.42-1.169-4.386-1.813-6.782h-1.456c-.62 2.396-1.05 4.194-1.694 6.782h-.096c-.071-.743-.477-9.73-.525-11.24h-2.648c.31 6.399.763 12.055 1.097 14.86h2.625l1.838-7.12z" />
</svg>
);
}
export function GhosttyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Ghostty</title>
<path d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95" />
</svg>
);
}
export function KittyIcon(props: SVGProps<SVGSVGElement>) {
// Official icon is complex and has fixed width/height/viewBox in original.
// Simplifying viewBox to 0 0 256 256 effectively as original was 240x240 but translated.
// Original viewBox="0 0 240 240" with g transform="translate(0 -812.362)" and elements around y=850.
// 850 - 812 = 38. So it's confusing.
// Let's copy the raw SVG content but adapt it to be a component.
// To make it behave like an icon, we should probably set viewBox="0 0 240 240" and keep the transform.
// It relies on fill colors. If we want it to be monochrome (currentColor), we should remove fills or set them to currentColor.
// However, official icons often have brand colors. The user said "official icon", which implies color.
// But usually in a dropdown we might want monochrome or original color.
// simple-icons are usually monochrome.
// Let's keep Kitty as original color since it's complex, OR mono if it works?
// The kitty icon has multiple paths with different colors. I'll preserve them for now as it's "official".
// If it looks weird in dark mode/light mode, we might need to adjust.
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 240 240"
{...props} // Allow overriding width/height
>
<g transform="translate(0 -812.362)">
<rect
width="100.446"
height="161.551"
x="72.824"
y="850.13"
ry="0"
style={{
fill: "#ddd",
fillOpacity: 1,
fillRule: "evenodd",
stroke: "none",
strokeWidth: 5.86876726,
strokeLinecap: "round",
strokeLinejoin: "round",
strokeMiterlimit: 4,
strokeDasharray: "none",
strokeOpacity: 1,
}}
/>
<path
d="M67.896 1029.71h104.208a7.065 7.065 0 0 0 7.065-7.066V918.436a7.065 7.065 0 0 0-7.065-7.065H67.896a7.065 7.065 0 0 0-7.065 7.065v104.208a7.065 7.065 0 0 0 7.065 7.065m55.813-38.35h37.444a4.239 4.239 0 0 1 0 8.479H123.71a4.239 4.239 0 0 1 0-8.478m-45.032-45.71a4.239 4.239 0 0 1 5.991-5.99l26.48 26.464a4.24 4.24 0 0 1 0 5.992l-26.48 26.48a4.239 4.239 0 0 1-5.991-5.992l23.484-23.484z"
style={{ strokeWidth: 1.41299629 }}
/>
<path
d="M96.085 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.385 3.574-3.385 8.17 0 4.595 1.504 8.17 3.385 8.17"
style={{
clipRule: "evenodd",
fill: "#c0c81f",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 3.09913683,
}}
/>
<path
d="M193.128 836.886c-4.596-4.85-25.53 1.022-38.295 8.936-9.957-5.106-21.956-8.17-34.721-8.17-13.02 0-25.02 3.064-34.977 8.17-12.765-7.914-33.955-14.042-38.295-8.936-4.595 5.106 3.32 26.296 12.765 38.04-.766 3.064-1.276 6.128-1.276 9.446 0 10.212 4.34 19.659 11.744 27.318h42.124c-1.276-2.553.511-4.085 8.17-4.085 7.659.255 9.19 1.532 8.17 4.085h42.124c7.404-7.66 11.744-17.36 11.744-27.318 0-3.318-.51-6.382-1.276-9.446 8.935-11.744 16.594-33.189 11.999-38.04m-97.015 67.4c-8.935 0-16.339-7.404-16.339-16.34s7.404-16.339 16.34-16.339 16.339 7.404 16.339 16.34-7.404 16.339-16.34 16.339m47.997 0c-8.936 0-16.34-7.404-16.34-16.34s7.404-16.339 16.34-16.339 16.34 7.404 16.34 16.34-7.15 16.339-16.34 16.339"
style={{
clipRule: "evenodd",
fill: "#784421",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 2.55301046,
}}
/>
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
<path
d="M168.507 903.265c15.318-19.148 46.72-28.339 67.655-15.063-24.509-3.83-46.72 2.553-67.655 15.063"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
<path
d="M167.486 898.67c8.68-20.425 34.466-33.7 55.145-26.552-21.7 2.808-39.316 11.233-55.145 26.551m-.256 9.957c15.83-15.063 50.806-20.169 61.528-4.34-21.7-6.893-40.593-3.83-61.527 4.34"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
</g>
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
<path
d="M71.493 903.265c-15.318-19.148-46.72-28.339-67.655-15.063 24.509-3.83 46.72 2.553 67.655 15.063"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
<path
d="M72.514 898.67c-8.68-20.425-34.466-33.7-55.145-26.552 21.7 2.808 39.316 11.233 55.145 26.551m.256 9.957c-15.83-15.063-50.806-20.169-61.528-4.34 21.7-6.893 40.593-3.83 61.527 4.34"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
</g>
<path
d="M52.6 893.563c-6.382 0-11.743 3.32-14.296 8.425h-.766c-6.893 0-12.765 5.106-12.765 11.489 0 8.935 9.19 13.786 17.615 10.722 5.106 7.404 16.084 7.915 20.17 0 6.126-.255 16.083-1.276 17.615-10.722 1.021-6.383-5.617-11.489-12.765-11.489h-.766c-2.042-5.106-7.659-8.425-14.041-8.425m134.8 0c6.382 0 11.743 3.32 14.296 8.425h.766c3.574 0 12.765 5.106 12.765 11.489 0 8.935-9.19 13.786-17.615 10.722-5.107 7.404-16.084 7.915-20.17 0-6.126-.255-16.083-1.276-17.615-10.722-1.021-6.383 9.19-11.489 12.765-11.489h.766c2.042-5.106 7.659-8.425 14.041-8.425"
style={{
clipRule: "evenodd",
fill: "#483737",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 2.55301046,
}}
/>
<path
d="M143.542 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.386 3.574-3.386 8.17 0 4.595 1.505 8.17 3.386 8.17"
style={{
clipRule: "evenodd",
fill: "#c0c81f",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 3.09913683,
}}
/>
</g>
</svg>
);
}
+136 -57
View File
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import {
useAllMcpServers,
useToggleMcpApp,
@@ -13,19 +13,19 @@ import type { McpServer } from "@/types";
import type { AppId } from "@/lib/api/types";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
import { Edit3, Trash2, ExternalLink } from "lucide-react";
import { Edit3, Trash2 } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { mcpPresets } from "@/config/mcpPresets";
import { toast } from "sonner";
import { APP_IDS } from "@/config/appConfig";
import { AppCountBar } from "@/components/common/AppCountBar";
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
import { ListItemRow } from "@/components/common/ListItemRow";
interface UnifiedMcpPanelProps {
onOpenChange: (open: boolean) => void;
}
/**
* MCP
* v3.7.0 MCP
*/
export interface UnifiedMcpPanelHandle {
openAdd: () => void;
openImport: () => void;
@@ -45,22 +45,26 @@ const UnifiedMcpPanel = React.forwardRef<
onConfirm: () => void;
} | null>(null);
// Queries and Mutations
const { data: serversMap, isLoading } = useAllMcpServers();
const toggleAppMutation = useToggleMcpApp();
const deleteServerMutation = useDeleteMcpServer();
const importMutation = useImportMcpFromApps();
// Convert serversMap to array for easier rendering
const serverEntries = useMemo((): Array<[string, McpServer]> => {
if (!serversMap) return [];
return Object.entries(serversMap);
}, [serversMap]);
// Count enabled servers per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
serverEntries.forEach(([_, server]) => {
for (const app of APP_IDS) {
if (server.apps[app]) counts[app]++;
}
if (server.apps.claude) counts.claude++;
if (server.apps.codex) counts.codex++;
if (server.apps.gemini) counts.gemini++;
if (server.apps.opencode) counts.opencode++;
});
return counts;
}, [serverEntries]);
@@ -73,7 +77,9 @@ const UnifiedMcpPanel = React.forwardRef<
try {
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
} catch (error) {
toast.error(t("common.error"), { description: String(error) });
toast.error(t("common.error"), {
description: String(error),
});
}
};
@@ -100,7 +106,9 @@ const UnifiedMcpPanel = React.forwardRef<
});
}
} catch (error) {
toast.error(t("common.error"), { description: String(error) });
toast.error(t("common.error"), {
description: String(error),
});
}
};
@@ -120,7 +128,9 @@ const UnifiedMcpPanel = React.forwardRef<
setConfirmDialog(null);
toast.success(t("common.success"), { closeButton: true });
} catch (error) {
toast.error(t("common.error"), { description: String(error) });
toast.error(t("common.error"), {
description: String(error),
});
}
},
});
@@ -133,11 +143,18 @@ const UnifiedMcpPanel = React.forwardRef<
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
<AppCountBar
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
counts={enabledCounts}
/>
{/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "}
{t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">
@@ -156,24 +173,22 @@ const UnifiedMcpPanel = React.forwardRef<
</p>
</div>
) : (
<TooltipProvider delayDuration={300}>
<div className="rounded-xl border border-border-default overflow-hidden">
{serverEntries.map(([id, server], index) => (
<UnifiedMcpListItem
key={id}
id={id}
server={server}
onToggleApp={handleToggleApp}
onEdit={handleEdit}
onDelete={handleDelete}
isLast={index === serverEntries.length - 1}
/>
))}
</div>
</TooltipProvider>
<div className="space-y-3">
{serverEntries.map(([id, server]) => (
<UnifiedMcpListItem
key={id}
id={id}
server={server}
onToggleApp={handleToggleApp}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
editingId={editingId || undefined}
@@ -190,6 +205,7 @@ const UnifiedMcpPanel = React.forwardRef<
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
@@ -205,13 +221,16 @@ const UnifiedMcpPanel = React.forwardRef<
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
/**
* MCP
*
*/
interface UnifiedMcpListItemProps {
id: string;
server: McpServer;
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
isLast?: boolean;
}
const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
@@ -220,12 +239,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
onToggleApp,
onEdit,
onDelete,
isLast,
}) => {
const { t } = useTranslation();
const name = server.name || id;
const description = server.description || "";
// 匹配预设元信息
const meta = mcpPresets.find((p) => p.id === id);
const docsUrl = server.docs || meta?.docs;
const homepageUrl = server.homepage || meta?.homepage;
@@ -242,66 +261,126 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
};
return (
<ListItemRow isLast={isLast}>
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
{/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm text-foreground truncate">
{name}
</span>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">{name}</h3>
{docsUrl && (
<button
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
className="text-muted-foreground/60 hover:text-foreground flex-shrink-0"
title={t("mcp.presets.docs")}
>
<ExternalLink size={12} />
</button>
{t("mcp.presets.docs")}
</Button>
)}
</div>
{description && (
<p
className="text-xs text-muted-foreground truncate"
title={description}
>
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-muted-foreground/60 truncate">
<p className="text-xs text-muted-foreground/70 truncate">
{tags.join(", ")}
</p>
)}
</div>
<AppToggleGroup
apps={server.apps}
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
/>
{/* 中间:应用开关 */}
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-claude`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
<Switch
id={`${id}-claude`}
checked={server.apps.claude}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "claude", checked)
}
/>
</div>
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-codex`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
<Switch
id={`${id}-codex`}
checked={server.apps.codex}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "codex", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-gemini`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
<Switch
id={`${id}-gemini`}
checked={server.apps.gemini}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "gemini", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-opencode`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.opencode")}
</label>
<Switch
id={`${id}-opencode`}
checked={server.apps.opencode}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "opencode", checked)
}
/>
</div>
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onEdit(id)}
title={t("common.edit")}
>
<Edit3 size={14} />
<Edit3 size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
onClick={() => onDelete(id)}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("common.delete")}
>
<Trash2 size={14} />
<Trash2 size={16} />
</Button>
</div>
</ListItemRow>
</div>
);
};
+3 -3
View File
@@ -7,13 +7,13 @@ export function ModeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const toggleTheme = (event: React.MouseEvent) => {
const toggleTheme = () => {
// 如果当前是 dark 或 system(且系统是暗色),切换到 light
// 否则切换到 dark
if (theme === "dark") {
setTheme("light", event);
setTheme("light");
} else {
setTheme("dark", event);
setTheme("dark");
}
};
+13 -1
View File
@@ -17,6 +17,7 @@ import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
interface AddProviderDialogProps {
@@ -35,6 +36,7 @@ export function AddProviderDialog({
onSubmit,
}: AddProviderDialogProps) {
const { t } = useTranslation();
// OpenCode doesn't support universal providers
const showUniversalTab = appId !== "opencode";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
@@ -43,6 +45,7 @@ export function AddProviderDialog({
const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null);
// Handle universal provider save
const handleUniversalProviderSave = useCallback(
async (provider: UniversalProvider) => {
try {
@@ -70,6 +73,7 @@ export function AddProviderDialog({
[t, onOpenChange],
);
// Close universal form and return to main dialog
const handleUniversalFormClose = useCallback(() => {
setUniversalFormOpen(false);
setSelectedUniversalPreset(null);
@@ -82,6 +86,7 @@ export function AddProviderDialog({
unknown
>;
// 构造基础提交数据
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
@@ -93,6 +98,7 @@ export function AddProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
// OpenCode: pass providerKey for ID generation
if (appId === "opencode" && values.providerKey) {
providerData.providerKey = values.providerKey;
}
@@ -101,7 +107,8 @@ export function AddProviderDialog({
providerData.meta?.custom_endpoints &&
Object.keys(providerData.meta.custom_endpoints).length > 0;
if (!hasCustomEndpoints && values.presetCategory !== "omo") {
if (!hasCustomEndpoints) {
// 收集端点候选(仅在缺少自定义端点时兜底)
const urlSet = new Set<string>();
const addUrl = (rawUrl?: string) => {
@@ -156,6 +163,7 @@ export function AddProviderDialog({
}
}
}
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
}
if (appId === "claude") {
@@ -179,6 +187,7 @@ export function AddProviderDialog({
addUrl(env.GOOGLE_GEMINI_BASE_URL);
}
} else if (appId === "opencode") {
// OpenCode uses options.baseURL
const options = parsedConfig.options as
| Record<string, any>
| undefined;
@@ -212,6 +221,7 @@ export function AddProviderDialog({
[appId, onSubmit, onOpenChange],
);
// 动态 footer:根据当前 Tab 显示不同按钮
const footer =
!showUniversalTab || activeTab === "app-specific" ? (
<>
@@ -286,6 +296,7 @@ export function AddProviderDialog({
</TabsContent>
</Tabs>
) : (
// OpenCode: directly show form without tabs
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
@@ -295,6 +306,7 @@ export function AddProviderDialog({
/>
)}
{/* Universal Provider Form Modal */}
{showUniversalTab && (
<UniversalProviderFormModal
isOpen={universalFormOpen}
+20 -40
View File
@@ -19,20 +19,20 @@ import type { AppId } from "@/lib/api";
interface ProviderActionsProps {
appId?: AppId;
isCurrent: boolean;
/** OpenCode: 是否已添加到配置 */
isInConfig?: boolean;
isTesting?: boolean;
isProxyTakeover?: boolean;
isOmo?: boolean;
isLastOmo?: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: () => void;
onDisableOmo?: () => void;
onOpenTerminal?: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
@@ -44,8 +44,6 @@ export function ProviderActions({
isInConfig = false,
isTesting,
isProxyTakeover = false,
isOmo = false,
isLastOmo = false,
onSwitch,
onEdit,
onDuplicate,
@@ -53,8 +51,8 @@ export function ProviderActions({
onConfigureUsage,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onOpenTerminal,
// 故障转移相关
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
onToggleFailover,
@@ -62,20 +60,19 @@ export function ProviderActions({
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
const isOpenCodeMode = appId === "opencode" && !isOmo;
// OpenCode 使用累加模式
const isOpenCodeMode = appId === "opencode";
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
const isFailoverMode =
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
!isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
// 处理主按钮点击
const handleMainButtonClick = () => {
if (isOmo) {
if (isCurrent) {
onDisableOmo?.();
} else {
onSwitch();
}
} else if (isOpenCodeMode) {
if (isOpenCodeMode) {
// OpenCode 模式:切换配置状态(添加/移除)
if (isInConfig) {
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
if (onRemoveFromConfig) {
onRemoveFromConfig();
} else {
@@ -85,33 +82,17 @@ export function ProviderActions({
onSwitch(); // 添加到配置
}
} else if (isFailoverMode) {
// 故障转移模式:切换队列状态
onToggleFailover(!isInFailoverQueue);
} else {
// 普通模式:切换供应商
onSwitch();
}
};
// 主按钮的状态和样式
const getMainButtonState = () => {
if (isOmo) {
if (isCurrent) {
return {
disabled: false,
variant: "secondary" as const,
className:
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
icon: <Check className="h-4 w-4" />,
text: t("provider.inUse"),
};
}
return {
disabled: false,
variant: "default" as const,
className: "",
icon: <Play className="h-4 w-4" />,
text: t("provider.enable"),
};
}
// OpenCode 累加模式
if (isOpenCodeMode) {
if (isInConfig) {
return {
@@ -133,6 +114,7 @@ export function ProviderActions({
};
}
// 故障转移模式
if (isFailoverMode) {
if (isInFailoverQueue) {
return {
@@ -154,6 +136,7 @@ export function ProviderActions({
};
}
// 普通模式
if (isCurrent) {
return {
disabled: true,
@@ -178,11 +161,8 @@ export function ProviderActions({
const buttonState = getMainButtonState();
const canDelete = isOmo
? !(isLastOmo && isCurrent)
: isOpenCodeMode
? true
: !isCurrent;
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
const canDelete = isOpenCodeMode ? true : !isCurrent;
return (
<div className="flex items-center gap-1.5">
+40 -22
View File
@@ -27,13 +27,11 @@ interface ProviderCardProps {
isCurrent: boolean;
appId: AppId;
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
isOmo?: boolean;
isLastOmo?: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
@@ -43,6 +41,7 @@ interface ProviderCardProps {
isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
dragHandleProps?: DragHandleProps;
// 故障转移相关
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...
isInFailoverQueue?: boolean; // 是否在故障转移队列中
@@ -51,14 +50,17 @@ interface ProviderCardProps {
}
const extractApiUrl = (provider: Provider, fallbackText: string) => {
// 优先级 1: 备注
if (provider.notes?.trim()) {
return provider.notes.trim();
}
// 优先级 2: 官网地址
if (provider.websiteUrl) {
return provider.websiteUrl;
}
// 优先级 3: 从配置中提取请求地址
const config = provider.settingsConfig;
if (config && typeof config === "object") {
@@ -87,13 +89,10 @@ export function ProviderCard({
isCurrent,
appId,
isInConfig = true,
isOmo = false,
isLastOmo = false,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onConfigureUsage,
onOpenWebsite,
onDuplicate,
@@ -103,6 +102,7 @@ export function ProviderCard({
isProxyRunning,
isProxyTakeover = false,
dragHandleProps,
// 故障转移相关
isAutoFailoverEnabled = false,
failoverPriority,
isInFailoverQueue = false,
@@ -111,6 +111,7 @@ export function ProviderCard({
}: ProviderCardProps) {
const { t } = useTranslation();
// 获取供应商健康状态
const { data: health } = useProviderHealth(provider.id, appId);
const fallbackUrlText = t("provider.notConfigured", {
@@ -121,18 +122,24 @@ export function ProviderCard({
return extractApiUrl(provider, fallbackUrlText);
}, [provider, fallbackUrlText]);
// 判断是否为可点击的 URL(备注不可点击)
const isClickableUrl = useMemo(() => {
// 如果有备注,则不可点击
if (provider.notes?.trim()) {
return false;
}
// 如果显示的是回退文本,也不可点击
if (displayUrl === fallbackUrlText) {
return false;
}
// 其他情况(官网地址或请求地址)可点击
return true;
}, [provider.notes, displayUrl, fallbackUrlText]);
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
// 获取用量数据以判断是否有多套餐
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0
@@ -146,17 +153,21 @@ export function ProviderCard({
const hasMultiplePlans =
usage?.success && usage.data && usage.data.length > 1;
// 多套餐默认展开
const [isExpanded, setIsExpanded] = useState(false);
// 操作按钮容器 ref,用于动态计算宽度
const actionsRef = useRef<HTMLDivElement>(null);
const [actionsWidth, setActionsWidth] = useState(0);
// 当检测到多套餐时自动展开
useEffect(() => {
if (hasMultiplePlans) {
setIsExpanded(true);
}
}, [hasMultiplePlans]);
// 动态获取操作按钮宽度
useEffect(() => {
if (actionsRef.current) {
const updateWidth = () => {
@@ -164,6 +175,7 @@ export function ProviderCard({
setActionsWidth(width);
};
updateWidth();
// 监听窗口大小变化
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}
@@ -176,27 +188,32 @@ export function ProviderCard({
onOpenWebsite(displayUrl);
};
const isActiveProvider = isOmo
? isCurrent
: appId === "opencode"
// 判断是否是"当前使用中"的供应商
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 代理接管模式(非故障转移):isCurrent
// - 普通模式:isCurrent
const isActiveProvider =
appId === "opencode"
? false
: isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
const shouldUseBlue =
(isOmo && isActiveProvider) ||
(!isOmo && !isProxyTakeover && isActiveProvider);
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
const shouldUseGreen = isProxyTakeover && isActiveProvider;
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
return (
<div
className={cn(
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
"bg-card text-card-foreground group",
// hover 时的边框效果
isAutoFailoverEnabled || isProxyTakeover
? "hover:border-emerald-500/50"
: "hover:border-border-active",
// 当前激活的供应商边框样式
shouldUseGreen &&
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
@@ -208,6 +225,7 @@ export function ProviderCard({
<div
className={cn(
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
shouldUseGreen && "from-emerald-500/10",
shouldUseBlue && "from-blue-500/10",
!isActiveProvider && "from-primary/10",
@@ -230,6 +248,7 @@ export function ProviderCard({
<GripVertical className="h-4 w-4" />
</button>
{/* 供应商图标 */}
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
@@ -245,18 +264,14 @@ export function ProviderCard({
{provider.name}
</h3>
{isOmo && (
<span className="inline-flex items-center rounded-md bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 dark:bg-violet-900/40 dark:text-violet-300">
OMO
</span>
)}
{/* 健康状态徽章 */}
{isProxyRunning && isInFailoverQueue && health && (
<ProviderHealthBadge
consecutiveFailures={health.consecutive_failures}
/>
)}
{/* 故障转移优先级徽章 */}
{isAutoFailoverEnabled &&
isInFailoverQueue &&
failoverPriority && (
@@ -303,8 +318,10 @@ export function ProviderCard({
} as React.CSSProperties
}
>
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<div className="ml-auto">
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
{hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">
@@ -325,6 +342,7 @@ export function ProviderCard({
inline={true}
/>
)}
{/* 展开/折叠按钮 - 仅在有多套餐时显示 */}
{hasMultiplePlans && (
<button
onClick={(e) => {
@@ -348,6 +366,7 @@ export function ProviderCard({
</div>
</div>
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
<div
ref={actionsRef}
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
@@ -358,8 +377,6 @@ export function ProviderCard({
isInConfig={isInConfig}
isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
isOmo={isOmo}
isLastOmo={isLastOmo}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
@@ -371,10 +388,10 @@ export function ProviderCard({
? () => onRemoveFromConfig(provider)
: undefined
}
onDisableOmo={onDisableOmo}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
@@ -383,6 +400,7 @@ export function ProviderCard({
</div>
</div>
{/* 展开的完整套餐列表 */}
{isExpanded && hasMultiplePlans && (
<div className="mt-4 pt-4 border-t border-border-default">
<UsageFooter
+48 -54
View File
@@ -20,6 +20,7 @@ import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { providersApi } from "@/lib/api/providers";
import { useDragSort } from "@/hooks/useDragSort";
import { useStreamCheck } from "@/hooks/useStreamCheck";
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
import {
@@ -28,7 +29,6 @@ import {
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
} from "@/lib/query/failover";
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -40,8 +40,8 @@ interface ProviderListProps {
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
@@ -61,7 +61,6 @@ export function ProviderList({
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -78,12 +77,14 @@ export function ProviderList({
appId,
);
// OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
const { data: opencodeLiveIds } = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode",
});
// OpenCode: 判断供应商是否已添加到 opencode.json
const isProviderInConfig = useCallback(
(providerId: string): boolean => {
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
@@ -92,18 +93,20 @@ export function ProviderList({
[appId, opencodeLiveIds],
);
// 流式健康检查
const { checkProvider, isChecking } = useStreamCheck(appId);
// 故障转移相关
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
const { data: failoverQueue } = useFailoverQueue(appId);
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
const isFailoverModeActive =
isProxyTakeover === true && isAutoFailoverEnabled === true;
const isOpenCode = appId === "opencode";
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
const getFailoverPriority = useCallback(
(providerId: string): number | undefined => {
if (!isFailoverModeActive || !failoverQueue) return undefined;
@@ -115,6 +118,7 @@ export function ProviderList({
[isFailoverModeActive, failoverQueue],
);
// 判断供应商是否在故障转移队列中
const isInFailoverQueue = useCallback(
(providerId: string): boolean => {
if (!isFailoverModeActive || !failoverQueue) return false;
@@ -123,6 +127,7 @@ export function ProviderList({
[isFailoverModeActive, failoverQueue],
);
// 切换供应商的故障转移队列状态
const handleToggleFailover = useCallback(
(providerId: string, enabled: boolean) => {
if (enabled) {
@@ -134,6 +139,10 @@ export function ProviderList({
[appId, addToQueue, removeFromQueue],
);
const handleTest = (provider: Provider) => {
checkProvider(provider.id, provider.name);
};
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -205,44 +214,35 @@ export function ProviderList({
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{filteredProviders.map((provider) => {
const isOmo = provider.category === "omo";
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
return (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={
isOmo ? isOmoCurrent : provider.id === currentProviderId
}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
isOmo={isOmo}
isLastOmo={
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDisableOmo={onDisableOmo}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
isTesting={false} // isChecking(provider.id) - 测试功能已隐藏
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
isAutoFailoverEnabled={isFailoverModeActive}
failoverPriority={getFailoverPriority(provider.id)}
isInFailoverQueue={isInFailoverQueue(provider.id)}
onToggleFailover={(enabled) =>
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
/>
);
})}
{filteredProviders.map((provider) => (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProviderId}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={appId !== "opencode" ? handleTest : undefined}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
// 故障转移相关:联动状态
isAutoFailoverEnabled={isFailoverModeActive}
failoverPriority={getFailoverPriority(provider.id)}
isInFailoverQueue={isInFailoverQueue(provider.id)}
onToggleFailover={(enabled) =>
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
/>
))}
</div>
</SortableContext>
</DndContext>
@@ -332,13 +332,11 @@ interface SortableProviderCardProps {
isCurrent: boolean;
appId: AppId;
isInConfig: boolean;
isOmo: boolean;
isLastOmo: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
@@ -347,6 +345,7 @@ interface SortableProviderCardProps {
isTesting: boolean;
isProxyRunning: boolean;
isProxyTakeover: boolean;
// 故障转移相关
isAutoFailoverEnabled: boolean;
failoverPriority?: number;
isInFailoverQueue: boolean;
@@ -359,13 +358,10 @@ function SortableProviderCard({
isCurrent,
appId,
isInConfig,
isOmo,
isLastOmo,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -401,13 +397,10 @@ function SortableProviderCard({
isCurrent={isCurrent}
appId={appId}
isInConfig={isInConfig}
isOmo={isOmo}
isLastOmo={isLastOmo}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDisableOmo={onDisableOmo}
onDuplicate={onDuplicate}
onConfigureUsage={
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
@@ -423,6 +416,7 @@ function SortableProviderCard({
listeners,
isDragging,
}}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
failoverPriority={failoverPriority}
isInFailoverQueue={isInFailoverQueue}
@@ -1,16 +1,10 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
import type { ProviderCategory } from "@/types";
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
interface EndpointCandidate {
@@ -65,9 +59,10 @@ interface ClaudeFormFieldsProps {
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];
// API Format (for third-party providers that use OpenAI Chat Completions format)
apiFormat: ClaudeApiFormat;
onApiFormatChange: (format: ClaudeApiFormat) => void;
// OpenRouter Compat
showOpenRouterCompatToggle: boolean;
openRouterCompatEnabled: boolean;
onOpenRouterCompatChange: (enabled: boolean) => void;
}
export function ClaudeFormFields({
@@ -100,8 +95,9 @@ export function ClaudeFormFields({
defaultOpusModel,
onModelChange,
speedTestEndpoints,
apiFormat,
onApiFormatChange,
showOpenRouterCompatToggle,
openRouterCompatEnabled,
onOpenRouterCompatChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
@@ -163,11 +159,7 @@ export function ClaudeFormFields({
value={baseUrl}
onChange={onBaseUrlChange}
placeholder={t("providerForm.apiEndpointPlaceholder")}
hint={
apiFormat === "openai_chat"
? t("providerForm.apiHintOAI")
: t("providerForm.apiHint")
}
hint={t("providerForm.apiHint")}
onManageClick={() => onEndpointModalToggle(true)}
/>
)}
@@ -188,34 +180,25 @@ export function ClaudeFormFields({
/>
)}
{/* API 格式选择(仅非官方供应商显示) */}
{shouldShowModelSelector && (
<div className="space-y-2">
<FormLabel htmlFor="apiFormat">
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
</FormLabel>
<Select value={apiFormat} onValueChange={onApiFormatChange}>
<SelectTrigger id="apiFormat" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">
{t("providerForm.apiFormatAnthropic", {
defaultValue: "Anthropic Messages (原生)",
})}
</SelectItem>
<SelectItem value="openai_chat">
{t("providerForm.apiFormatOpenAIChat", {
defaultValue: "OpenAI Chat Completions (需转换)",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.apiFormatHint", {
defaultValue: "选择供应商 API 的输入格式",
})}
</p>
{showOpenRouterCompatToggle && (
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("providerForm.openrouterCompatMode", {
defaultValue: "OpenRouter 兼容模式",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("providerForm.openrouterCompatModeHint", {
defaultValue:
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
})}
</p>
</div>
<Switch
checked={openRouterCompatEnabled}
onCheckedChange={onOpenRouterCompatChange}
/>
</div>
)}
@@ -1,161 +0,0 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Save, FolderInput, Loader2 } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import {
OmoGlobalConfigFields,
type OmoGlobalConfigFieldsRef,
} from "./OmoGlobalConfigFields";
import type { OmoGlobalConfig } from "@/types/omo";
interface OmoCommonConfigEditorProps {
previewValue: string;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
isModalOpen: boolean;
onEditClick: () => void;
onModalClose: () => void;
onSave: () => Promise<void>;
isSaving: boolean;
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
fieldsKey: number;
}
export function OmoCommonConfigEditor({
previewValue,
useCommonConfig,
onCommonConfigToggle,
isModalOpen,
onEditClick,
onModalClose,
onSave,
isSaving,
onGlobalConfigStateChange,
globalConfigRef,
fieldsKey,
}: OmoCommonConfigEditorProps) {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const [isImporting, setIsImporting] = useState(false);
useEffect(() => {
const syncDarkMode = () =>
setIsDarkMode(document.documentElement.classList.contains("dark"));
syncDarkMode();
const observer = new MutationObserver(syncDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleImportLocal = async () => {
if (!globalConfigRef.current) return;
setIsImporting(true);
try {
await globalConfigRef.current.importFromLocal();
} finally {
setIsImporting(false);
}
};
return (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("provider.configJson")}</Label>
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>
{t("omo.writeCommonConfig", {
defaultValue: "Write to common config",
})}
</span>
</label>
</div>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditClick}
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{t("omo.editCommonConfig", { defaultValue: "Edit common config" })}
</button>
</div>
<JsonEditor
value={previewValue}
onChange={() => {}}
darkMode={isDarkMode}
rows={14}
showValidation={false}
language="json"
/>
</div>
<FullScreenPanel
isOpen={isModalOpen}
title={t("omo.editCommonConfigTitle", {
defaultValue: "Edit OMO Common Config",
})}
onClose={onModalClose}
footer={
<>
<Button
type="button"
variant="outline"
onClick={handleImportLocal}
disabled={isImporting}
className="gap-2"
>
{isImporting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<FolderInput className="w-4 h-4" />
)}
{t("common.import", { defaultValue: "Import" })}
</Button>
<Button type="button" variant="outline" onClick={onModalClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={onSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("omo.commonConfigHint", {
defaultValue:
"OMO common config will be merged into all OMO configs that enable it",
})}
</p>
<OmoGlobalConfigFields
key={fieldsKey}
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
onStateChange={onGlobalConfigStateChange}
hideSaveButtons
/>
</div>
</FullScreenPanel>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More