Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ab9cd1371 | |||
| 2edee31638 | |||
| 62fa5213bf | |||
| caba5f51ec | |||
| 0c6d429fdb | |||
| b9afcbf2b0 | |||
| e349012abc | |||
| 594e0d52f7 | |||
| 11404d4d96 | |||
| 0fcb1b01e2 | |||
| ea20b0aec2 | |||
| ed47480fc7 | |||
| 5502c74a79 | |||
| fa7c9514cc | |||
| d82027f107 | |||
| ded9980fbf | |||
| 3d91c381d9 | |||
| b8538a6996 | |||
| 87b80c66b2 | |||
| bf0be82112 | |||
| 14fa749ca9 | |||
| 95bc0e38df | |||
| c7b8e6debc | |||
| 92785a8078 | |||
| 1d97570a94 | |||
| d98183f3da | |||
| 68a0c304d8 | |||
| 60007ee4e8 | |||
| 7bd29d721e | |||
| c153e7104e | |||
| 6634a95c38 | |||
| e65360e68a | |||
| 1580e87303 | |||
| f0e8ba1d8f | |||
| 58153333ce | |||
| d098ecad64 | |||
| 809a1fcf84 | |||
| e5fea048a1 | |||
| ea29bc20a0 | |||
| b8bd1d30d9 | |||
| 0ef8c127c5 | |||
| 4abf259a6d | |||
| faa82a5b86 | |||
| b5b45c2703 | |||
| bd8a323600 | |||
| 151e43a808 | |||
| d66f196378 | |||
| 580b32dd67 | |||
| 9d2bf08fe0 | |||
| 05c21e016f | |||
| 57713dd336 | |||
| 1ed122a8bd | |||
| 78e341ccb9 | |||
| 162800e18e | |||
| 065d5db843 | |||
| 70a18c1141 | |||
| 964767ebaf | |||
| 0c25687e09 | |||
| 81b975c47c | |||
| e44423c307 | |||
| 51bf29582d | |||
| aa1231903f | |||
| b8a53f9e36 | |||
| 06cde78945 | |||
| 0d67fba524 | |||
| 3b61fab4b5 | |||
| 982f78af0f | |||
| 9776210da8 | |||
| 08d9bb4cab | |||
| 987fc46e06 | |||
| e3d335be2d | |||
| 0dd823ae3a | |||
| 188c7af2e4 | |||
| de59facad6 | |||
| 7974650ad8 | |||
| 3f68f78331 | |||
| 164635f638 | |||
| fcb5163710 | |||
| 3095bf8e5c | |||
| a48502235c | |||
| 40373538d3 | |||
| 11a9bb0c14 | |||
| 9e25ecf475 | |||
| c74f801d66 | |||
| 785e1b5add | |||
| 733605ae5c | |||
| 542d4635c4 | |||
| 4e4a445922 | |||
| d5d7c87fd6 | |||
| 1e1080c813 | |||
| e393dda68e | |||
| 0ecf6891c0 | |||
| cfab768f95 | |||
| 73fea48049 | |||
| e54e4d47ae | |||
| 403c3f0690 | |||
| b3b3c0732a | |||
| 2ff329f6c0 | |||
| 5817c9aa77 | |||
| 6425826e66 | |||
| 2f18764490 | |||
| 7ad5a76c7a | |||
| b1446d0227 | |||
| 3da25e59cd | |||
| 0938bd5e41 | |||
| 62523ee17e | |||
| 0e75193e84 | |||
| 078a3a6a53 | |||
| d52f8855ea | |||
| 628a659c0e | |||
| 86cfa452e9 | |||
| 06d69caf79 | |||
| 58f7be1517 | |||
| 5d48dd7908 | |||
| 0e7e04581d | |||
| 788e138ece | |||
| b692bb4053 |
@@ -20,6 +20,8 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- os: windows-2022
|
- os: windows-2022
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
|
- os: ubuntu-22.04-arm
|
||||||
|
arch: arm64
|
||||||
- os: macos-14
|
- os: macos-14
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -57,7 +59,8 @@ jobs:
|
|||||||
rpm \
|
rpm \
|
||||||
flatpak \
|
flatpak \
|
||||||
flatpak-builder \
|
flatpak-builder \
|
||||||
elfutils
|
elfutils \
|
||||||
|
xdg-utils
|
||||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||||
sudo apt-get install -y --no-install-recommends \
|
sudo apt-get install -y --no-install-recommends \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
@@ -85,8 +88,8 @@ jobs:
|
|||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.path }}
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: ${{ runner.os }}-pnpm-store-
|
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
|
||||||
|
|
||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -256,10 +259,11 @@ jobs:
|
|||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
||||||
|
ARCH="${{ matrix.arch || 'x86_64' }}"
|
||||||
# Updater artifact: AppImage(含对应 .sig)
|
# Updater artifact: AppImage(含对应 .sig)
|
||||||
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
||||||
if [ -n "$APPIMAGE" ]; then
|
if [ -n "$APPIMAGE" ]; then
|
||||||
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
|
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux-${ARCH}.AppImage"
|
||||||
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
|
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
|
||||||
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
|
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
|
||||||
echo "AppImage copied: $NEW_APPIMAGE"
|
echo "AppImage copied: $NEW_APPIMAGE"
|
||||||
@@ -269,18 +273,16 @@ jobs:
|
|||||||
# 额外上传 .deb(用于手动安装,不参与 Updater)
|
# 额外上传 .deb(用于手动安装,不参与 Updater)
|
||||||
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
||||||
if [ -n "$DEB" ]; then
|
if [ -n "$DEB" ]; then
|
||||||
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
|
cp "$DEB" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.deb"
|
||||||
cp "$DEB" "release-assets/$NEW_DEB"
|
echo "Deb package copied: CC-Switch-${VERSION}-Linux-${ARCH}.deb"
|
||||||
echo "Deb package copied: $NEW_DEB"
|
|
||||||
else
|
else
|
||||||
echo "No .deb found (optional)"
|
echo "No .deb found (optional)"
|
||||||
fi
|
fi
|
||||||
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater)
|
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater)
|
||||||
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
|
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
|
||||||
if [ -n "$RPM" ]; then
|
if [ -n "$RPM" ]; then
|
||||||
NEW_RPM="CC-Switch-${VERSION}-Linux.rpm"
|
cp "$RPM" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
|
||||||
cp "$RPM" "release-assets/$NEW_RPM"
|
echo "RPM package copied: CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
|
||||||
echo "RPM package copied: $NEW_RPM"
|
|
||||||
else
|
else
|
||||||
echo "No .rpm found (optional)"
|
echo "No .rpm found (optional)"
|
||||||
fi
|
fi
|
||||||
@@ -312,7 +314,8 @@ jobs:
|
|||||||
|
|
||||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
- **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`(绿色版)
|
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||||
- **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)
|
- **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`
|
||||||
|
|
||||||
---
|
---
|
||||||
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||||
@@ -360,7 +363,8 @@ jobs:
|
|||||||
# 初始化空平台映射
|
# 初始化空平台映射
|
||||||
mac_url=""; mac_sig=""
|
mac_url=""; mac_sig=""
|
||||||
win_url=""; win_sig=""
|
win_url=""; win_sig=""
|
||||||
linux_url=""; linux_sig=""
|
linux_x64_url=""; linux_x64_sig=""
|
||||||
|
linux_arm64_url=""; linux_arm64_sig=""
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for sig in dl/*.sig; do
|
for sig in dl/*.sig; do
|
||||||
base=${sig%.sig}
|
base=${sig%.sig}
|
||||||
@@ -371,8 +375,10 @@ jobs:
|
|||||||
*.tar.gz)
|
*.tar.gz)
|
||||||
# 视为 macOS updater artifact
|
# 视为 macOS updater artifact
|
||||||
mac_url="$url"; mac_sig="$sig_content";;
|
mac_url="$url"; mac_sig="$sig_content";;
|
||||||
*.AppImage|*.appimage)
|
*-Linux-arm64.AppImage|*-Linux-arm64.appimage)
|
||||||
linux_url="$url"; linux_sig="$sig_content";;
|
linux_arm64_url="$url"; linux_arm64_sig="$sig_content";;
|
||||||
|
*-Linux-x86_64.AppImage|*-Linux-x86_64.appimage)
|
||||||
|
linux_x64_url="$url"; linux_x64_sig="$sig_content";;
|
||||||
*.msi|*.exe)
|
*.msi|*.exe)
|
||||||
win_url="$url"; win_sig="$sig_content";;
|
win_url="$url"; win_sig="$sig_content";;
|
||||||
esac
|
esac
|
||||||
@@ -399,9 +405,14 @@ jobs:
|
|||||||
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
|
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
|
||||||
first=0
|
first=0
|
||||||
fi
|
fi
|
||||||
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
|
if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then
|
||||||
[ $first -eq 0 ] && echo ','
|
[ $first -eq 0 ] && echo ','
|
||||||
echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}"
|
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\"}"
|
||||||
first=0
|
first=0
|
||||||
fi
|
fi
|
||||||
echo ' }'
|
echo ' }'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ release/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.npmrc
|
.npmrc
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
# AGENTS.md
|
||||||
GEMINI.md
|
GEMINI.md
|
||||||
/.claude
|
/.claude
|
||||||
/.codex
|
/.codex
|
||||||
|
|||||||
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [3.10.2] - 2026-01-24
|
||||||
|
|||||||
@@ -15,16 +15,18 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
|||||||
|
|
||||||
## ❤️Sponsor
|
## ❤️Sponsor
|
||||||
|
|
||||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
[](https://bit.ly/3Nue8mA)
|
||||||
|
|
||||||
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
|
MiniMax M2.1 is an open-source, SOTA model built for real-world development and agentic workflows. It delivers top-tier performance on major coding benchmarks such as SWE, VIBE, and Multi-SWE. Powered by a 10B active / 230B total MoE architecture, M2.1 enables faster inference, easier deployment, and even local execution. It excels at coding, navigating digital environments, and handling long, multi-step tasks at scale.
|
||||||
|
|
||||||
|
[Click](https://bit.ly/3Nue8mA) to get an exclusive 12% off the MiniMax Coding Plan!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
|
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -33,8 +35,9 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<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 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 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>
|
<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>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -42,6 +45,21 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
<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>
|
<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>
|
||||||
|
|
||||||
|
<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://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||||
|
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and <strong>supports quota rollovers—unused quota from one day can be carried over and used the next day.</strong> Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.</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>
|
||||||
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
|
|
||||||
## ❤️スポンサー
|
## ❤️スポンサー
|
||||||
|
|
||||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
[](https://bit.ly/3Nue8mA)
|
||||||
|
|
||||||
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
|
MiniMax M2.1 は、実務開発とエージェントワークフロー向けに構築されたオープンソースの最先端モデルです。100 億のアクティブパラメータ / 2,300 億の総パラメータを持つ MoE アーキテクチャにより、高速な推論、簡単なデプロイ、ローカル実行にも対応します。SWE、VIBE、Multi-SWE などの主要コーディングベンチマークでトップクラスの性能を発揮し、コーディング、デジタル環境のナビゲーション、大規模な多段階タスクの処理に優れています。
|
||||||
|
|
||||||
|
[こちら](https://bit.ly/3Nue8mA)から MiniMax Coding Plan の限定 12% オフを入手!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,8 +35,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<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 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>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>
|
<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>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -42,6 +45,21 @@
|
|||||||
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</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>
|
||||||
|
|
||||||
|
<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://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||||
|
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して提供しています。特に高いコストパフォーマンスを誇る Codex の月額プランを主力としており、<strong>未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)</strong>点が特長です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>からご登録いただくと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。</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>
|
||||||
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## スクリーンショット
|
## スクリーンショット
|
||||||
|
|||||||
@@ -15,22 +15,36 @@
|
|||||||
|
|
||||||
## ❤️赞助商
|
## ❤️赞助商
|
||||||
|
|
||||||
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
[](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
|
||||||
|
|
||||||
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
|
MiniMax M2.x 系列模型是面向实际开发与智能体工作流打造的编码模型,M2.1 基于 100 亿激活 / 2300 亿总参的混合专家架构打造,推理更快、部署更便捷且支持本地运行,在 SWE、VIBE、Multi-SWE 等主流代码评测基准中均表现顶尖,擅长代码开发、数字环境适配及规模化处理长链路多步骤任务。
|
||||||
|
|
||||||
|
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠!</td>
|
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<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 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>
|
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||||
</tr>
|
</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>
|
<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 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用全球大模型。
|
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
|
||||||
@@ -38,8 +52,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<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 width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" 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>
|
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打<strong>极高性价比</strong>的Codex包月套餐,<strong>提供额度转结,套餐当天用不完的额度,第二天还能接着用!</strong>充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额25%的按量额度!</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 —— 全球大模型 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>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* 统一供应商(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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 1.1 软件介绍
|
||||||
|
|
||||||
|
## 什么是 CC Switch
|
||||||
|
|
||||||
|
CC Switch 是一款跨平台桌面应用,专为使用 AI 编程工具的开发者设计。它帮助你统一管理 **Claude Code**、**Codex**、**Gemini CLI**、**OpenCode** 四大 AI 编程工具的配置。
|
||||||
|
|
||||||
|
## 解决什么问题
|
||||||
|
|
||||||
|
在日常开发中,你可能会遇到这些痛点:
|
||||||
|
|
||||||
|
- **多供应商切换麻烦**:使用不同的 API 供应商(官方、中转服务商),需要手动修改配置文件
|
||||||
|
- **配置分散难管理**:Claude、Codex、Gemini、OpenCode 各有独立的配置文件,格式不同
|
||||||
|
- **无法监控用量**:不知道 API 调用了多少次,花了多少钱
|
||||||
|
- **服务不稳定**:单一供应商出问题时,整个工作流中断
|
||||||
|
|
||||||
|
CC Switch 通过统一的界面解决这些问题。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 供应商管理
|
||||||
|
- 一键切换多个 API 供应商配置
|
||||||
|
- 支持预设模板,快速添加常用供应商
|
||||||
|
- 统一供应商功能,跨应用共享配置
|
||||||
|
- 用量查询与余额显示
|
||||||
|
- 端点速度测试
|
||||||
|
|
||||||
|
### 扩展功能
|
||||||
|
- **MCP 服务器**:管理 Model Context Protocol 服务器,扩展 AI 能力
|
||||||
|
- **Prompts**:管理系统提示词预设,快速切换不同场景
|
||||||
|
- **Skills**:安装和管理技能扩展
|
||||||
|
|
||||||
|
### 代理与高可用
|
||||||
|
- 本地代理服务,记录请求日志和用量统计
|
||||||
|
- 自动故障转移,主供应商失败时自动切换备用
|
||||||
|
- 熔断器机制,防止频繁重试失败的供应商
|
||||||
|
- 详细的 Token 用量追踪与成本估算
|
||||||
|
|
||||||
|
## 支持的应用
|
||||||
|
|
||||||
|
| 应用 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **Claude Code** | Anthropic 官方的 AI 编程助手 |
|
||||||
|
| **Codex** | OpenAI 的代码生成工具 |
|
||||||
|
| **Gemini CLI** | Google 的 AI 命令行工具 |
|
||||||
|
| **OpenCode** | 开源 AI 编程终端工具 |
|
||||||
|
|
||||||
|
## 支持的平台
|
||||||
|
|
||||||
|
- **Windows** 10 及以上
|
||||||
|
- **macOS** 10.15 (Catalina) 及以上
|
||||||
|
- **Linux** Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
CC Switch 使用现代化的技术栈构建:
|
||||||
|
|
||||||
|
- **前端**:React 18 + TypeScript + Tailwind CSS
|
||||||
|
- **后端**:Tauri 2 + Rust
|
||||||
|
- **数据存储**:SQLite(供应商、MCP、Prompts)+ JSON(设备设置)
|
||||||
|
|
||||||
|
这种架构确保了:
|
||||||
|
- 跨平台一致的体验
|
||||||
|
- 原生级别的性能
|
||||||
|
- 安全的本地数据存储
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# 1.2 安装指南
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
### 安装 Node.js
|
||||||
|
|
||||||
|
CC Switch 管理的 CLI 工具(Claude Code、Codex、Gemini CLI)需要 Node.js 环境。
|
||||||
|
|
||||||
|
**推荐版本**:Node.js 18 LTS 或更高版本
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
1. 访问 [Node.js 官网](https://nodejs.org/)
|
||||||
|
|
||||||
|
2. 下载 LTS 版本安装包
|
||||||
|
|
||||||
|
3. 运行安装程序,按提示完成安装
|
||||||
|
|
||||||
|
4. 验证安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 Homebrew 安装
|
||||||
|
brew install node
|
||||||
|
|
||||||
|
# 或使用 nvm(推荐)
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||||
|
nvm install --lts
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
# 或使用 nvm
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||||
|
nvm install --lts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安装 CLI 工具
|
||||||
|
|
||||||
|
#### Claude Code
|
||||||
|
|
||||||
|
**方式一:Homebrew(macOS 推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install claude-code
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:npm**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
|
# 国内用户如下载慢,使用镜像源
|
||||||
|
npm install -g @anthropic-ai/claude-code --registry=https://registry.npmmirror.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Codex
|
||||||
|
|
||||||
|
**方式一:Homebrew(macOS 推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install codex
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:npm**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @openai/codex
|
||||||
|
|
||||||
|
# 国内用户如下载慢,使用镜像源
|
||||||
|
npm install -g @openai/codex --registry=https://registry.npmmirror.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Gemini CLI
|
||||||
|
|
||||||
|
**方式一:Homebrew(macOS 推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install gemini-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:npm**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @google/gemini-cli
|
||||||
|
|
||||||
|
# 国内用户如下载慢,使用镜像源
|
||||||
|
npm install -g @google/gemini-cli --registry=https://registry.npmmirror.com
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 **提示**:如果经常遇到下载慢的问题,可以全局设置镜像源:
|
||||||
|
> ```bash
|
||||||
|
> npm config set registry https://registry.npmmirror.com
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
### 安装包方式
|
||||||
|
|
||||||
|
1. 访问 [Releases 页面](https://github.com/farion1231/cc-switch/releases)
|
||||||
|
2. 下载 `CC-Switch-v{版本号}-Windows.msi`
|
||||||
|
3. 双击运行安装程序
|
||||||
|
4. 按提示完成安装
|
||||||
|
|
||||||
|
### 绿色版(免安装)
|
||||||
|
|
||||||
|
1. 下载 `CC-Switch-v{版本号}-Windows-Portable.zip`
|
||||||
|
2. 解压到任意目录
|
||||||
|
3. 运行 `CC-Switch.exe`
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
### 方式一:Homebrew(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 添加 tap
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
|
||||||
|
# 安装
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新到最新版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:手动下载
|
||||||
|
|
||||||
|
1. 下载 `CC-Switch-v{版本号}-macOS.zip`
|
||||||
|
2. 解压得到 `CC Switch.app`
|
||||||
|
3. 拖动到「应用程序」文件夹
|
||||||
|
|
||||||
|
### 首次打开提示
|
||||||
|
|
||||||
|
由于开发者没有 Apple 开发者账号,首次打开可能出现「未知开发者」警告:
|
||||||
|
|
||||||
|
**推荐解决方法**:
|
||||||
|
打开终端执行以下命令:
|
||||||
|
```bash
|
||||||
|
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||||
|
```
|
||||||
|
|
||||||
|
**备选解决方法(通过系统设置)**:
|
||||||
|
1. 关闭警告弹窗
|
||||||
|
2. 打开「系统设置」→「隐私与安全性」
|
||||||
|
3. 找到 CC Switch 相关提示,点击「仍要打开」
|
||||||
|
4. 再次打开应用即可正常使用
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
### ArchLinux
|
||||||
|
|
||||||
|
使用 AUR 助手安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 paru
|
||||||
|
paru -S cc-switch-bin
|
||||||
|
|
||||||
|
# 或使用 yay
|
||||||
|
yay -S cc-switch-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debian / Ubuntu
|
||||||
|
|
||||||
|
1. 下载 `CC-Switch-v{版本号}-Linux.deb`
|
||||||
|
2. 安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dpkg -i CC-Switch-v{版本号}-Linux.deb
|
||||||
|
|
||||||
|
# 如果有依赖问题
|
||||||
|
sudo apt-get install -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### AppImage(通用)
|
||||||
|
|
||||||
|
1. 下载 `CC-Switch-v{版本号}-Linux.AppImage`
|
||||||
|
2. 添加执行权限:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x CC-Switch-v{版本号}-Linux.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./CC-Switch-v{版本号}-Linux.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证安装
|
||||||
|
|
||||||
|
安装完成后,启动 CC Switch:
|
||||||
|
|
||||||
|
1. 应用窗口正常显示
|
||||||
|
2. 系统托盘出现 CC Switch 图标
|
||||||
|
3. 能够切换 Claude / Codex / Gemini 三个应用
|
||||||
|
|
||||||
|
## 自动更新
|
||||||
|
|
||||||
|
CC Switch 内置自动更新功能:
|
||||||
|
|
||||||
|
- 启动时自动检查更新
|
||||||
|
- 有新版本时在界面显示更新提示
|
||||||
|
- 点击即可下载并安装
|
||||||
|
|
||||||
|
也可以在「设置 → 关于」中手动检查更新。
|
||||||
|
|
||||||
|
## 卸载
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
- 通过「设置 → 应用」卸载
|
||||||
|
- 或运行安装目录下的卸载程序
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
- 将 `CC Switch.app` 移到废纸篓
|
||||||
|
- 可选:删除配置目录 `~/.cc-switch/`
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt remove cc-switch
|
||||||
|
|
||||||
|
# ArchLinux
|
||||||
|
paru -R cc-switch-bin
|
||||||
|
```
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# 1.3 界面概览
|
||||||
|
|
||||||
|
## 主界面布局
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 顶部导航栏
|
||||||
|
|
||||||
|
| 序号 | 元素 | 功能说明 |
|
||||||
|
|------|------|----------|
|
||||||
|
| ① | Logo | 点击访问 GitHub 项目页 |
|
||||||
|
| ② | 设置按钮 | 打开设置页面(快捷键 `Cmd/Ctrl + ,`) |
|
||||||
|
| ③ | 代理开关 | 启动/停止本地代理服务 |
|
||||||
|
| ④ | 应用切换器 | 切换 Claude / Codex / Gemini / OpenCode |
|
||||||
|
| ⑤ | 功能区 | Skills / Prompts / MCP 入口 |
|
||||||
|
| ⑥ | 添加按钮 | 添加新供应商 |
|
||||||
|
|
||||||
|
### 应用切换器
|
||||||
|
|
||||||
|
点击下拉菜单切换当前管理的应用:
|
||||||
|
|
||||||
|
- **Claude** - 管理 Claude Code 配置
|
||||||
|
- **Codex** - 管理 Codex 配置
|
||||||
|
- **Gemini** - 管理 Gemini CLI 配置
|
||||||
|
- **OpenCode** - 管理 OpenCode 配置
|
||||||
|
|
||||||
|
切换后,供应商列表会显示对应应用的配置。
|
||||||
|
|
||||||
|
### 功能区按钮
|
||||||
|
|
||||||
|
| 按钮 | 功能 | 可见条件 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Skills | 技能扩展管理 | 始终可见 |
|
||||||
|
| Prompts | 系统提示词管理 | 始终可见 |
|
||||||
|
| MCP | MCP 服务器管理 | 始终可见 |
|
||||||
|
|
||||||
|
## 供应商卡片
|
||||||
|
|
||||||
|
每个供应商以卡片形式展示,从左到右依次包含以下元素:
|
||||||
|
|
||||||
|
### 卡片元素(从左到右)
|
||||||
|
|
||||||
|
| 序号 | 元素 | 图标 | 功能说明 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| ① | 拖拽手柄 | ≡ | 按住上下拖动调整供应商顺序 |
|
||||||
|
| ② | 供应商图标 | 🔷 | 显示供应商品牌图标,可自定义颜色 |
|
||||||
|
| ③ | 供应商信息 | - | 名称、备注/端点地址(可点击打开官网) |
|
||||||
|
| ④ | 用量信息 | - | 显示剩余额度,多套餐时显示套餐数量 |
|
||||||
|
| ⑤ | 启用按钮 | ▶ | 切换为当前使用的供应商 |
|
||||||
|
| ⑥ | 编辑按钮 | ✏️ | 编辑供应商配置 |
|
||||||
|
| ⑦ | 复制按钮 | 📋 | 复制供应商(创建副本) |
|
||||||
|
| ⑧ | 测速按钮 | 🧪 | 测试模型可用性和响应速度 |
|
||||||
|
| ⑨ | 用量查询 | 📊 | 配置用量查询脚本 |
|
||||||
|
| ⑩ | 删除按钮 | 🗑️ | 删除供应商(当前启用时禁用) |
|
||||||
|
|
||||||
|
> 💡 **提示**:操作按钮区域(⑤-⑩)在鼠标悬停时显示,平时隐藏以保持界面简洁。
|
||||||
|
|
||||||
|
### 按钮详细说明
|
||||||
|
|
||||||
|
| 按钮 | 状态变化 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **启用** | 已启用时显示 ✓ 并禁用 | 故障转移模式下变为「加入/已加入」 |
|
||||||
|
| **编辑** | 始终可用 | 打开编辑面板修改配置 |
|
||||||
|
| **复制** | 始终可用 | 创建供应商副本,名称后缀 `copy` |
|
||||||
|
| **测速** | 测试中显示加载动画 | 仅代理服务运行时可用 |
|
||||||
|
| **用量查询** | 始终可用 | 配置自定义用量查询脚本 |
|
||||||
|
| **删除** | 当前启用时半透明禁用 | 需先切换到其他供应商才能删除 |
|
||||||
|
|
||||||
|
### 卡片状态
|
||||||
|
|
||||||
|
| 状态 | 边框颜色 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **当前启用** | 🔵 蓝色边框 | 普通模式下当前使用的供应商 |
|
||||||
|
| **代理活跃** | 🟢 绿色边框 | 代理接管模式下实际使用的供应商 |
|
||||||
|
| **普通状态** | 默认边框 | 未启用的供应商 |
|
||||||
|
| **故障转移中** | 显示优先级徽章 | 如 P1、P2 表示故障转移优先级 |
|
||||||
|
|
||||||
|
### 健康状态徽章
|
||||||
|
|
||||||
|
在代理模式下,加入故障转移队列的供应商会显示健康状态:
|
||||||
|
|
||||||
|
| 徽章 | 颜色 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 健康 | 🟢 绿色 | 连续失败 0 次 |
|
||||||
|
| 警告 | 🟡 黄色 | 连续失败 1-2 次 |
|
||||||
|
| 不健康 | 🔴 红色 | 连续失败 ≥3 次,可能触发熔断 |
|
||||||
|
|
||||||
|
|
||||||
|
## 系统托盘
|
||||||
|
|
||||||
|
CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||||
|
|
||||||
|
### 托盘菜单结构
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 菜单功能
|
||||||
|
|
||||||
|
| 菜单项 | 功能 |
|
||||||
|
|--------|------|
|
||||||
|
| 打开主界面 | 显示主窗口并聚焦 |
|
||||||
|
| 应用分组 | 按 Claude/Codex/Gemini/OpenCode 分组显示供应商 |
|
||||||
|
| 供应商列表 | 点击切换,当前启用的显示勾选标记 |
|
||||||
|
| 退出 | 完全退出应用 |
|
||||||
|
|
||||||
|
### 多语言支持
|
||||||
|
|
||||||
|
托盘菜单支持三种语言,根据设置自动切换:
|
||||||
|
|
||||||
|
| 语言 | 打开主界面 | 退出 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| 中文 | 打开主界面 | 退出 |
|
||||||
|
| English | Open main window | Quit |
|
||||||
|
| 日本語 | メインウィンドウを開く | 終了 |
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
托盘切换供应商无需打开主界面,适合:
|
||||||
|
|
||||||
|
- 频繁切换供应商
|
||||||
|
- 主窗口最小化时快速操作
|
||||||
|
- 后台运行时管理配置
|
||||||
|
|
||||||
|
## 设置页面
|
||||||
|
|
||||||
|
设置页面分为多个 Tab:
|
||||||
|
|
||||||
|
### 通用 Tab
|
||||||
|
|
||||||
|
- 语言设置(中文/English/日本語)
|
||||||
|
- 主题设置(跟随系统/浅色/深色)
|
||||||
|
- 窗口行为(开机自启、关闭行为)
|
||||||
|
|
||||||
|
### 高级 Tab
|
||||||
|
|
||||||
|
- 配置目录设置
|
||||||
|
- 代理服务配置
|
||||||
|
- 故障转移设置
|
||||||
|
- 定价配置
|
||||||
|
- 数据导入导出
|
||||||
|
|
||||||
|
### 用量 Tab
|
||||||
|
|
||||||
|
- 请求统计概览
|
||||||
|
- 趋势图表
|
||||||
|
- 请求日志
|
||||||
|
- 供应商/模型统计
|
||||||
|
|
||||||
|
### 关于 Tab
|
||||||
|
|
||||||
|
- 版本信息
|
||||||
|
- 更新检查
|
||||||
|
- 开源协议
|
||||||
|
|
||||||
|
## 快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 功能 |
|
||||||
|
|--------|------|
|
||||||
|
| `Cmd/Ctrl + ,` | 打开设置 |
|
||||||
|
| `Cmd/Ctrl + F` | 搜索供应商 |
|
||||||
|
| `Esc` | 关闭弹窗/搜索 |
|
||||||
|
|
||||||
|
## 搜索功能
|
||||||
|
|
||||||
|
按 `Cmd/Ctrl + F` 打开搜索框:
|
||||||
|
|
||||||
|
- 支持按名称、备注、URL 搜索
|
||||||
|
- 实时过滤供应商列表
|
||||||
|
- 按 `Esc` 关闭搜索
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 1.4 快速上手
|
||||||
|
|
||||||
|
本节帮助你在 5 分钟内完成首次配置。
|
||||||
|
|
||||||
|
## 第一步:添加供应商
|
||||||
|
|
||||||
|
1. 点击主界面右上角的 **+** 按钮
|
||||||
|
2. 在「预设」下拉框中选择你的供应商
|
||||||
|
- 常用预设:智谱 GLM、MiniMax、DeepSeek、Kimi、PackyCode
|
||||||
|
- 或选择「自定义」手动配置
|
||||||
|
3. 填写 **API Key**
|
||||||
|
4. 点击「添加」
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 💡 **提示**:预设会自动填充端点地址,你只需要填写 API Key。
|
||||||
|
|
||||||
|
## 第二步:切换供应商
|
||||||
|
|
||||||
|
添加完成后,供应商会出现在列表中。
|
||||||
|
|
||||||
|
**方式一:主界面切换**
|
||||||
|
- 点击供应商卡片的「启用」按钮
|
||||||
|
|
||||||
|
**方式二:托盘快速切换**
|
||||||
|
- 右键系统托盘图标
|
||||||
|
- 直接点击供应商名称
|
||||||
|
|
||||||
|
## 第三步:生效方式
|
||||||
|
|
||||||
|
切换供应商后,各 CLI 工具的生效方式不同:
|
||||||
|
|
||||||
|
| 应用 | 生效方式 |
|
||||||
|
|------|----------|
|
||||||
|
| Claude Code | ✅ 即时生效(支持热重载) |
|
||||||
|
| Codex | 需关闭并重新打开终端 |
|
||||||
|
| Gemini | ✅ 即时生效(每次请求重新读取配置) |
|
||||||
|
|
||||||
|
### Claude Code 首次安装提示
|
||||||
|
|
||||||
|
如果 Claude Code 首次启动时提示需要**登录**或显示初始化引导,请在 CC Switch 中开启「跳过 Claude Code 初次安装确认」选项:
|
||||||
|
|
||||||
|
1. 打开 CC Switch「设置 → 通用」
|
||||||
|
2. 开启「跳过 Claude Code 初次安装确认」开关
|
||||||
|
3. 重新启动 Claude Code
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> ⚠️ **注意**:此选项会写入 `~/.claude/settings.json` 的 `skipIntroduction` 字段,跳过官方的新手引导流程。
|
||||||
|
|
||||||
|
## 验证配置
|
||||||
|
|
||||||
|
重启后,启动对应的 CLI 工具并输入简单的问题进行测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Claude Code - 启动后输入测试问题
|
||||||
|
claude
|
||||||
|
> 你好,请简单介绍一下自己
|
||||||
|
|
||||||
|
# Codex - 启动后输入测试问题
|
||||||
|
codex
|
||||||
|
> 你好,请简单介绍一下自己
|
||||||
|
|
||||||
|
# Gemini - 启动后输入测试问题
|
||||||
|
gemini
|
||||||
|
> 你好,请简单介绍一下自己
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 AI 能正常回复,说明配置成功。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
恭喜!你已经完成了基础配置。接下来可以:
|
||||||
|
|
||||||
|
- [添加更多供应商](../2-providers/2.1-add.md) - 配置多个供应商方便切换
|
||||||
|
- [配置 MCP 服务器](../3-extensions/3.1-mcp.md) - 扩展 AI 工具的能力
|
||||||
|
- [设置系统提示词](../3-extensions/3.2-prompts.md) - 自定义 AI 的行为
|
||||||
|
- [开启代理服务](../4-proxy/4.1-service.md) - 监控用量和自动故障转移
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 切换后不生效?
|
||||||
|
|
||||||
|
确保重启了终端或 CLI 工具。配置文件在切换时已经更新,但运行中的程序不会自动重新加载。
|
||||||
|
|
||||||
|
### 找不到预设?
|
||||||
|
|
||||||
|
如果你的供应商不在预设列表中,选择「自定义」手动配置。参考 [添加供应商](../2-providers/2.1-add.md) 了解配置格式。
|
||||||
|
|
||||||
|
### 如何恢复官方登录?
|
||||||
|
|
||||||
|
选择「官方登录」预设(Claude/Codex)或「Google 官方」预设(Gemini),重启客户端后按登录流程操作。
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# 1.5 个性化配置
|
||||||
|
|
||||||
|
本节介绍如何根据个人偏好配置 CC Switch。
|
||||||
|
|
||||||
|
## 打开设置
|
||||||
|
|
||||||
|
- 点击左上角 **⚙️** 按钮
|
||||||
|
- 或使用快捷键 `Cmd/Ctrl + ,`
|
||||||
|
|
||||||
|
## 语言设置
|
||||||
|
|
||||||
|
CC Switch 支持三种语言:
|
||||||
|
|
||||||
|
| 语言 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 简体中文 | 默认语言 |
|
||||||
|
| English | 英文界面 |
|
||||||
|
| 日本語 | 日文界面 |
|
||||||
|
|
||||||
|
切换语言后立即生效,无需重启。
|
||||||
|
|
||||||
|
## 主题设置
|
||||||
|
|
||||||
|
| 选项 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 跟随系统 | 自动匹配系统的深色/浅色模式 |
|
||||||
|
| 浅色 | 始终使用浅色主题 |
|
||||||
|
| 深色 | 始终使用深色主题 |
|
||||||
|
|
||||||
|
## 窗口行为
|
||||||
|
|
||||||
|
### 开机自启
|
||||||
|
|
||||||
|
开启后,系统启动时自动运行 CC Switch。
|
||||||
|
|
||||||
|
- **Windows**:通过注册表实现
|
||||||
|
- **macOS**:通过 LaunchAgent 实现
|
||||||
|
- **Linux**:通过 XDG autostart 实现
|
||||||
|
|
||||||
|
### 关闭行为
|
||||||
|
|
||||||
|
| 选项 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 最小化到托盘 | 点击关闭按钮时隐藏到系统托盘 |
|
||||||
|
| 直接退出 | 点击关闭按钮时完全退出应用 |
|
||||||
|
|
||||||
|
推荐使用「最小化到托盘」,方便通过托盘快速切换供应商。
|
||||||
|
|
||||||
|
### Claude 插件集成
|
||||||
|
|
||||||
|
开启后,CC Switch 在切换供应商时会自动同步配置到 VS Code 中的 Claude Code 插件(写入 `~/.claude/config.json` 的 `primaryApiKey`)。
|
||||||
|
|
||||||
|
> 💡 **使用场景**:如果你同时使用 Claude Code CLI 和 VS Code 插件,开启此选项可以保持两者配置一致。
|
||||||
|
|
||||||
|
### 跳过 Claude 引导
|
||||||
|
|
||||||
|
开启后,跳过 Claude Code 的新手引导流程,适合已熟悉 Claude Code 的用户。
|
||||||
|
|
||||||
|
> ⚠️ **注意**:此选项会写入 `~/.claude/settings.json` 的 `skipIntroduction` 字段。
|
||||||
|
|
||||||
|
## 目录配置
|
||||||
|
|
||||||
|
### 应用配置目录
|
||||||
|
|
||||||
|
CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`。
|
||||||
|
|
||||||
|
### CLI 工具目录
|
||||||
|
|
||||||
|
可以自定义各 CLI 工具的配置目录:
|
||||||
|
|
||||||
|
| 配置 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| Claude 目录 | `~/.claude/` | Claude Code 配置目录 |
|
||||||
|
| Codex 目录 | `~/.codex/` | Codex 配置目录 |
|
||||||
|
| Gemini 目录 | `~/.gemini/` | Gemini CLI 配置目录 |
|
||||||
|
|
||||||
|
> ⚠️ **注意**:修改目录后需要重启应用,且对应的 CLI 工具也需要配置相同的目录。
|
||||||
|
|
||||||
|
## 数据管理
|
||||||
|
|
||||||
|
### 导出配置
|
||||||
|
|
||||||
|
点击「导出」按钮,保存包含以下内容的备份文件:
|
||||||
|
|
||||||
|
- 所有供应商配置
|
||||||
|
- MCP 服务器配置
|
||||||
|
- Prompts 预设
|
||||||
|
- 应用设置
|
||||||
|
|
||||||
|
备份文件格式为 JSON,可以用文本编辑器查看。
|
||||||
|
|
||||||
|
### 导入配置
|
||||||
|
|
||||||
|
1. 点击「选择文件」
|
||||||
|
2. 选择之前导出的备份文件
|
||||||
|
3. 点击「导入」
|
||||||
|
4. 确认覆盖现有配置
|
||||||
|
|
||||||
|
> ⚠️ **注意**:导入会覆盖现有配置,建议先导出当前配置作为备份。
|
||||||
|
|
||||||
|
## 关于页面
|
||||||
|
|
||||||
|
设置 → 关于 Tab
|
||||||
|
|
||||||
|
### 版本信息
|
||||||
|
|
||||||
|
显示当前 CC Switch 版本号,支持:
|
||||||
|
- 查看发布说明
|
||||||
|
- 检查更新
|
||||||
|
- 下载并安装新版本
|
||||||
|
|
||||||
|
### 本地环境检查
|
||||||
|
|
||||||
|
自动检测已安装的 CLI 工具版本:
|
||||||
|
|
||||||
|
| 工具 | 检测内容 |
|
||||||
|
|------|----------|
|
||||||
|
| Claude | 当前版本、最新版本 |
|
||||||
|
| Codex | 当前版本、最新版本 |
|
||||||
|
| Gemini | 当前版本、最新版本 |
|
||||||
|
|
||||||
|
点击「刷新」按钮可重新检测。
|
||||||
|
|
||||||
|
### 一键安装命令
|
||||||
|
|
||||||
|
提供快速安装/更新 CLI 工具的命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -g @anthropic-ai/claude-code@latest
|
||||||
|
npm i -g @openai/codex@latest
|
||||||
|
npm i -g @google/gemini-cli@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
点击「复制」按钮可复制到剪贴板。
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# 2.1 添加供应商
|
||||||
|
|
||||||
|
## 打开添加面板
|
||||||
|
|
||||||
|
点击主界面右上角的 **+** 按钮,打开添加供应商面板。
|
||||||
|
|
||||||
|
面板分为两个 Tab:
|
||||||
|
- **应用专属供应商**:仅用于当前选中的应用(Claude/Codex/Gemini/OpenCode)
|
||||||
|
- **统一供应商**:跨应用共享的配置
|
||||||
|
|
||||||
|
## 使用预设添加
|
||||||
|
|
||||||
|
预设是预先配置好的供应商模板,只需填写 API Key 即可使用。
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 在「预设」下拉框中选择供应商
|
||||||
|
2. 名称和端点会自动填充
|
||||||
|
3. 填写你的 **API Key**
|
||||||
|
4. (可选)填写备注
|
||||||
|
5. 点击「添加」
|
||||||
|
|
||||||
|
### 常用预设
|
||||||
|
|
||||||
|
#### Claude 预设
|
||||||
|
|
||||||
|
| 预设名称 | 说明 |
|
||||||
|
|----------|------|
|
||||||
|
| Claude 官方 | 使用 Anthropic 官方账号登录 |
|
||||||
|
| DeepSeek | DeepSeek 模型 |
|
||||||
|
| 智谱 GLM | 智谱 AI 的 GLM 模型 |
|
||||||
|
| 智谱 GLM en | 智谱 AI(英文版) |
|
||||||
|
| 百炼 | 阿里云百炼(通义千问) |
|
||||||
|
| Kimi | Moonshot Kimi 模型 |
|
||||||
|
| Kimi For Coding | Kimi 编程专用模型 |
|
||||||
|
| ModelScope | 魔搭社区 |
|
||||||
|
| KAT-Coder | KAT-Coder 模型 |
|
||||||
|
| Longcat | Longcat AI |
|
||||||
|
| MiniMax | MiniMax 模型 |
|
||||||
|
| MiniMax en | MiniMax(英文版) |
|
||||||
|
| DouBaoSeed | 豆包 Seed 模型 |
|
||||||
|
| BaiLing | 百灵 AI |
|
||||||
|
| AiHubMix | AiHubMix 聚合服务 |
|
||||||
|
| SiliconFlow | 硅基流动 |
|
||||||
|
| SiliconFlow en | 硅基流动(英文版) |
|
||||||
|
| DMXAPI | DMXAPI 中转服务 |
|
||||||
|
| PackyCode | PackyCode 中转服务 ⭐ |
|
||||||
|
| Cubence | Cubence 服务 |
|
||||||
|
| AIGoCode | AIGoCode 服务 |
|
||||||
|
| RightCode | RightCode 服务 |
|
||||||
|
| AICodeMirror | AICodeMirror 服务 |
|
||||||
|
| OpenRouter | 聚合路由服务 |
|
||||||
|
| Nvidia | Nvidia AI 服务 |
|
||||||
|
| Xiaomi MiMo | 小米 MiMo 模型 |
|
||||||
|
|
||||||
|
> ⭐ 标注为官方合作伙伴。预设列表可能随版本更新,以应用内实际显示为准。
|
||||||
|
|
||||||
|
#### Codex 预设
|
||||||
|
|
||||||
|
| 预设名称 | 说明 |
|
||||||
|
|----------|------|
|
||||||
|
| OpenAI 官方 | 使用 OpenAI 官方账号登录 |
|
||||||
|
| Azure OpenAI | Azure OpenAI 服务 |
|
||||||
|
| AiHubMix | AiHubMix 聚合服务 |
|
||||||
|
| DMXAPI | DMXAPI 中转服务 |
|
||||||
|
| PackyCode | PackyCode 中转服务 |
|
||||||
|
| Cubence | Cubence 服务 |
|
||||||
|
| AIGoCode | AIGoCode 服务 |
|
||||||
|
| RightCode | RightCode 服务 |
|
||||||
|
| AICodeMirror | AICodeMirror 服务 |
|
||||||
|
| OpenRouter | 聚合路由服务 |
|
||||||
|
|
||||||
|
#### Gemini 预设
|
||||||
|
|
||||||
|
| 预设名称 | 说明 |
|
||||||
|
|----------|------|
|
||||||
|
| Google 官方 | 使用 Google OAuth 登录 |
|
||||||
|
| PackyCode | PackyCode 中转服务 |
|
||||||
|
| Cubence | Cubence 服务 |
|
||||||
|
| AIGoCode | AIGoCode 服务 |
|
||||||
|
| AICodeMirror | AICodeMirror 服务 |
|
||||||
|
| OpenRouter | 聚合路由服务 |
|
||||||
|
| 自定义 | 手动配置所有参数 |
|
||||||
|
|
||||||
|
#### OpenCode 预设
|
||||||
|
|
||||||
|
| 预设名称 | 说明 |
|
||||||
|
|----------|------|
|
||||||
|
| DeepSeek | DeepSeek 模型 |
|
||||||
|
| 智谱 GLM | 智谱 AI 的 GLM 模型 |
|
||||||
|
| 智谱 GLM en | 智谱 AI(英文版) |
|
||||||
|
| 百炼 | 阿里云百炼 |
|
||||||
|
| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |
|
||||||
|
| Kimi For Coding | Kimi 编程专用模型 |
|
||||||
|
| ModelScope | 魔搭社区 |
|
||||||
|
| KAT-Coder | KAT-Coder 模型 |
|
||||||
|
| Longcat | Longcat AI |
|
||||||
|
| MiniMax | MiniMax 模型 |
|
||||||
|
| MiniMax en | MiniMax(英文版) |
|
||||||
|
| DouBaoSeed | 豆包 Seed 模型 |
|
||||||
|
| BaiLing | 百灵 AI |
|
||||||
|
| Xiaomi MiMo | 小米 MiMo 模型 |
|
||||||
|
| AiHubMix | AiHubMix 聚合服务 |
|
||||||
|
| DMXAPI | DMXAPI 中转服务 |
|
||||||
|
| OpenRouter | 聚合路由服务 |
|
||||||
|
| Nvidia | Nvidia AI 服务 |
|
||||||
|
| PackyCode | PackyCode 中转服务 |
|
||||||
|
| Cubence | Cubence 服务 |
|
||||||
|
| AIGoCode | AIGoCode 服务 |
|
||||||
|
| RightCode | RightCode 服务 |
|
||||||
|
| AICodeMirror | AICodeMirror 服务 |
|
||||||
|
| OpenAI Compatible | OpenAI 兼容接口 |
|
||||||
|
| Oh My OpenCode | Oh My OpenCode 服务 |
|
||||||
|
|
||||||
|
> 💡 预设列表持续更新中,以应用内实际显示为准。
|
||||||
|
|
||||||
|
## 自定义配置
|
||||||
|
|
||||||
|
选择「自定义」预设后,需要手动编辑 JSON 配置。
|
||||||
|
|
||||||
|
### Claude 配置格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "your-api-key",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `ANTHROPIC_API_KEY` | 是 | API 密钥 |
|
||||||
|
| `ANTHROPIC_BASE_URL` | 否 | 自定义端点地址 |
|
||||||
|
| `ANTHROPIC_AUTH_TOKEN` | 否 | 替代 API_KEY 的认证方式 |
|
||||||
|
|
||||||
|
### Codex 配置格式
|
||||||
|
|
||||||
|
Codex 使用两个配置文件:
|
||||||
|
|
||||||
|
**1. auth.json**(`~/.codex/auth.json`)- 存储 API 密钥:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OPENAI_API_KEY": "your-api-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. config.toml**(`~/.codex/config.toml`)- 存储模型和端点配置:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# 基础配置
|
||||||
|
model_provider = "custom"
|
||||||
|
model = "gpt-5.2"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
disable_response_storage = true
|
||||||
|
|
||||||
|
# 自定义供应商配置
|
||||||
|
[model_providers.custom]
|
||||||
|
name = "custom"
|
||||||
|
base_url = "https://api.example.com/v1"
|
||||||
|
wire_api = "responses"
|
||||||
|
requires_openai_auth = true
|
||||||
|
```
|
||||||
|
|
||||||
|
**auth.json 字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `OPENAI_API_KEY` | 是 | API 密钥 |
|
||||||
|
|
||||||
|
**config.toml 字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `model_provider` | 是 | 模型提供商名称(需与 `[model_providers.xxx]` 匹配) |
|
||||||
|
| `model` | 是 | 使用的模型(如 `gpt-5.2`、`gpt-4o`) |
|
||||||
|
| `model_reasoning_effort` | 否 | 推理强度:`low` / `medium` / `high` |
|
||||||
|
| `disable_response_storage` | 否 | 是否禁用响应存储 |
|
||||||
|
| `base_url` | 是 | API 端点地址 |
|
||||||
|
| `wire_api` | 否 | API 协议类型(通常为 `responses`) |
|
||||||
|
| `requires_openai_auth` | 否 | 是否使用 OpenAI 认证方式 |
|
||||||
|
|
||||||
|
|
||||||
|
### Gemini 配置格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "your-api-key",
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://api.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GEMINI_API_KEY` | 是 | API 密钥 |
|
||||||
|
| `GOOGLE_GEMINI_BASE_URL` | 否 | 自定义端点地址 |
|
||||||
|
| `GEMINI_MODEL` | 否 | 指定模型 |
|
||||||
|
|
||||||
|
> 💡 认证类型由 CC Switch 自动检测(PackyCode API 代理 / Google OAuth / 通用 API Key),无需手动配置。
|
||||||
|
|
||||||
|
## 统一供应商
|
||||||
|
|
||||||
|
统一供应商可以跨 Claude/Codex/Gemini/OpenCode 共享配置,适用于支持多种 API 格式的中转服务。
|
||||||
|
|
||||||
|
### 创建统一供应商
|
||||||
|
|
||||||
|
1. 切换到「统一供应商」Tab
|
||||||
|
2. 点击「添加统一供应商」
|
||||||
|
3. 填写通用配置:
|
||||||
|
- 名称
|
||||||
|
- API Key
|
||||||
|
- 端点地址
|
||||||
|
4. 勾选要同步的应用(Claude/Codex/Gemini/OpenCode)
|
||||||
|
5. 保存
|
||||||
|
|
||||||
|
### 同步机制
|
||||||
|
|
||||||
|
统一供应商会自动同步到勾选的应用:
|
||||||
|
|
||||||
|
- 修改统一供应商后,所有关联应用的配置同步更新
|
||||||
|
- 删除统一供应商后,关联的应用配置也会删除
|
||||||
|
|
||||||
|
### 保存并同步
|
||||||
|
|
||||||
|
编辑统一供应商时,可以选择:
|
||||||
|
|
||||||
|
| 操作 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 保存 | 仅保存配置,不立即同步 |
|
||||||
|
| 保存并同步 | 保存配置并立即同步到所有启用的应用 |
|
||||||
|
|
||||||
|
### 手动同步
|
||||||
|
|
||||||
|
如果需要手动触发同步:
|
||||||
|
|
||||||
|
1. 在统一供应商卡片上点击「同步」按钮
|
||||||
|
2. 确认同步操作
|
||||||
|
3. 配置会覆盖各应用中关联的供应商
|
||||||
|
|
||||||
|
## 导入供应商
|
||||||
|
|
||||||
|
CC Switch 支持两种方式导入供应商配置:
|
||||||
|
|
||||||
|
### 方式一:深度链接导入
|
||||||
|
|
||||||
|
通过 `ccswitch://` 协议链接一键导入:
|
||||||
|
|
||||||
|
1. 点击或访问深度链接
|
||||||
|
2. CC Switch 自动打开并显示导入确认
|
||||||
|
3. 预览配置信息
|
||||||
|
4. 点击「确认导入」
|
||||||
|
|
||||||
|
**获取深度链接**:
|
||||||
|
- 从他人分享获取
|
||||||
|
- 使用 [在线生成工具](https://farion1231.github.io/cc-switch/deplink.html) 创建
|
||||||
|
|
||||||
|
### 方式二:数据库备份导入
|
||||||
|
|
||||||
|
从 SQL 备份文件批量导入:
|
||||||
|
|
||||||
|
1. 打开「设置 → 高级 → 数据管理」
|
||||||
|
2. 点击「选择文件」
|
||||||
|
3. 选择之前导出的 `.sql` 备份文件
|
||||||
|
4. 点击「导入」
|
||||||
|
5. 确认覆盖现有配置
|
||||||
|
|
||||||
|
**导入内容**:
|
||||||
|
- 所有供应商配置
|
||||||
|
- MCP 服务器配置
|
||||||
|
- Prompts 预设
|
||||||
|
- 用量日志
|
||||||
|
|
||||||
|
> ⚠️ **注意**:导入会覆盖现有数据库,建议先导出当前配置作为备份。导出的文件名格式为 `cc-switch-export-{时间戳}.sql`。
|
||||||
|
|
||||||
|
## 高级选项
|
||||||
|
|
||||||
|
### 自定义图标
|
||||||
|
|
||||||
|
点击名称左侧的图标区域,可以:
|
||||||
|
|
||||||
|
- 选择预设图标
|
||||||
|
- 自定义图标颜色
|
||||||
|
|
||||||
|
### 网站链接
|
||||||
|
|
||||||
|
填写供应商的官网或控制台地址,方便快速访问:
|
||||||
|
|
||||||
|
- 点击供应商卡片的链接图标可直接打开
|
||||||
|
- 用于查看余额、获取 API Key 等
|
||||||
|
|
||||||
|
### 备注
|
||||||
|
|
||||||
|
添加备注信息,如:
|
||||||
|
|
||||||
|
- 账号用途(个人/工作)
|
||||||
|
- 套餐信息
|
||||||
|
- 到期时间
|
||||||
|
|
||||||
|
备注会显示在供应商卡片上,也支持搜索。
|
||||||
|
|
||||||
|
### 端点测速
|
||||||
|
|
||||||
|
添加供应商后,可以对 API 端点进行速度测试:
|
||||||
|
|
||||||
|
1. 点击供应商卡片的「测速」按钮
|
||||||
|
2. 在测速面板中添加多个端点 URL
|
||||||
|
3. 点击「测速」执行测试
|
||||||
|
4. 选择延迟最低的端点
|
||||||
|
|
||||||
|
**测速结果**:
|
||||||
|
- 🟢 绿色:延迟 < 500ms(优秀)
|
||||||
|
- 🟡 黄色:延迟 500-1000ms(一般)
|
||||||
|
- 🔴 红色:延迟 > 1000ms(较慢)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# 2.2 切换供应商
|
||||||
|
|
||||||
|
## 主界面切换
|
||||||
|
|
||||||
|
在供应商列表中,点击目标供应商卡片的「启用」按钮。
|
||||||
|
|
||||||
|
### 切换流程
|
||||||
|
|
||||||
|
1. 点击「启用」按钮
|
||||||
|
2. CC Switch 更新配置文件
|
||||||
|
3. 卡片状态变为「当前启用」
|
||||||
|
4. Claude/Gemini 即时生效,Codex 需重启终端
|
||||||
|
|
||||||
|
### 状态指示
|
||||||
|
|
||||||
|
| 状态 | 显示 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 当前启用 | 蓝色边框 + 标签 | 配置文件中的当前供应商 |
|
||||||
|
| 代理活跃 | 绿色边框 | 代理模式下实际使用的供应商 |
|
||||||
|
| 普通 | 默认样式 | 未启用的供应商 |
|
||||||
|
|
||||||
|
## 托盘快速切换
|
||||||
|
|
||||||
|
通过系统托盘可以快速切换,无需打开主界面。
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 右键点击系统托盘的 CC Switch 图标
|
||||||
|
2. 在菜单中找到对应应用(Claude/Codex/Gemini/OpenCode)
|
||||||
|
3. 点击要切换的供应商名称
|
||||||
|
4. 切换完成,托盘会短暂提示
|
||||||
|
|
||||||
|
### 托盘菜单结构
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 生效方式
|
||||||
|
|
||||||
|
### Claude Code
|
||||||
|
|
||||||
|
**切换后即时生效**,无需重启。
|
||||||
|
|
||||||
|
Claude Code 支持热重载,会自动检测配置文件变更并重新加载。
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
切换后需要重启:
|
||||||
|
- 关闭当前终端窗口
|
||||||
|
- 重新打开终端
|
||||||
|
|
||||||
|
### Gemini CLI
|
||||||
|
|
||||||
|
**切换后即时生效**,无需重启。
|
||||||
|
|
||||||
|
Gemini CLI 每次请求都会重新读取 `.env` 文件。
|
||||||
|
|
||||||
|
## 配置文件变更
|
||||||
|
|
||||||
|
切换供应商时,CC Switch 会修改以下文件:
|
||||||
|
|
||||||
|
### Claude
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.claude/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
修改内容:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "新的 API Key",
|
||||||
|
"ANTHROPIC_BASE_URL": "新的端点"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.codex/auth.json
|
||||||
|
~/.codex/config.toml(如有额外配置)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.gemini/.env
|
||||||
|
~/.gemini/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 切换失败处理
|
||||||
|
|
||||||
|
如果切换失败,可能的原因:
|
||||||
|
|
||||||
|
### 配置文件被锁定
|
||||||
|
|
||||||
|
其他程序正在使用配置文件。
|
||||||
|
|
||||||
|
**解决方法**:关闭正在运行的 CLI 工具,再尝试切换。
|
||||||
|
|
||||||
|
### 权限不足
|
||||||
|
|
||||||
|
没有写入配置文件的权限。
|
||||||
|
|
||||||
|
**解决方法**:检查配置目录的权限设置。
|
||||||
|
|
||||||
|
### 配置格式错误
|
||||||
|
|
||||||
|
供应商配置的 JSON 格式有误。
|
||||||
|
|
||||||
|
**解决方法**:编辑供应商,检查并修复 JSON 格式。
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# 2.3 编辑供应商
|
||||||
|
|
||||||
|
## 打开编辑面板
|
||||||
|
|
||||||
|
1. 找到要编辑的供应商卡片
|
||||||
|
2. 鼠标悬停在卡片上,显示操作按钮
|
||||||
|
3. 点击「编辑」按钮
|
||||||
|
|
||||||
|
## 可编辑内容
|
||||||
|
|
||||||
|
### 基本信息
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 名称 | 供应商显示名称 |
|
||||||
|
| 备注 | 附加说明信息 |
|
||||||
|
| 网站链接 | 供应商官网或控制台地址 |
|
||||||
|
| 图标 | 自定义图标和颜色 |
|
||||||
|
|
||||||
|
### 图标自定义
|
||||||
|
|
||||||
|
CC Switch 提供丰富的图标自定义功能:
|
||||||
|
|
||||||
|
#### 图标选择器
|
||||||
|
|
||||||
|
1. 点击图标区域打开图标选择器
|
||||||
|
2. 使用搜索框按名称搜索图标
|
||||||
|
3. 点击选择想要的图标
|
||||||
|
|
||||||
|
图标库包含常见的 AI 服务商和技术图标,支持:
|
||||||
|
- 按名称模糊搜索
|
||||||
|
- 显示图标名称提示
|
||||||
|
- 实时预览选中效果
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 配置信息
|
||||||
|
|
||||||
|
JSON 格式的配置内容,包括:
|
||||||
|
|
||||||
|
- API Key
|
||||||
|
- 端点地址
|
||||||
|
- 其他环境变量
|
||||||
|
|
||||||
|
### 编辑当前启用的供应商
|
||||||
|
|
||||||
|
编辑当前启用的供应商时,有特殊的「回填」机制:
|
||||||
|
|
||||||
|
1. 打开编辑面板时,会从 live 配置文件读取最新内容
|
||||||
|
2. 如果你在 CLI 工具中手动修改过配置,这些修改会被同步回来
|
||||||
|
3. 保存后,修改会写入 live 配置文件
|
||||||
|
|
||||||
|
这确保了 CC Switch 和 CLI 工具的配置始终同步。
|
||||||
|
|
||||||
|
## 修改 API Key
|
||||||
|
|
||||||
|
编辑供应商时,可以直接在 **API Key** 输入框中修改:
|
||||||
|
|
||||||
|
1. 点击供应商卡片的「编辑」按钮
|
||||||
|
2. 在「API Key」输入框中输入新的密钥
|
||||||
|
3. 点击「保存」
|
||||||
|
|
||||||
|
> 💡 **提示**:API Key 输入框支持显示/隐藏切换,点击右侧的眼睛图标可查看完整密钥。
|
||||||
|
|
||||||
|
## 修改端点地址
|
||||||
|
|
||||||
|
编辑供应商时,可以直接在 **端点地址** 输入框中修改:
|
||||||
|
|
||||||
|
1. 点击供应商卡片的「编辑」按钮
|
||||||
|
2. 在「端点地址」输入框中输入新的 URL
|
||||||
|
3. 点击「保存」
|
||||||
|
|
||||||
|
### 端点地址格式
|
||||||
|
|
||||||
|
| 应用 | 格式示例 |
|
||||||
|
|------|----------|
|
||||||
|
| Claude | `https://api.example.com` |
|
||||||
|
| Codex | `https://api.example.com/v1` |
|
||||||
|
| Gemini | `https://api.example.com` |
|
||||||
|
|
||||||
|
## 添加自定义端点
|
||||||
|
|
||||||
|
供应商可以配置多个端点,用于:
|
||||||
|
|
||||||
|
- 速度测试时测试多个地址
|
||||||
|
- 故障转移时的备用端点
|
||||||
|
|
||||||
|
### 自动收集
|
||||||
|
|
||||||
|
添加供应商时,CC Switch 会自动从配置中提取端点地址。
|
||||||
|
|
||||||
|
### 手动添加
|
||||||
|
|
||||||
|
编辑供应商时,在「端点管理」区域可以:
|
||||||
|
|
||||||
|
- 添加新端点
|
||||||
|
- 删除现有端点
|
||||||
|
- 设置默认端点
|
||||||
|
|
||||||
|
## JSON 编辑器
|
||||||
|
|
||||||
|
配置使用 JSON 格式,编辑器提供:
|
||||||
|
|
||||||
|
- 语法高亮
|
||||||
|
- 格式校验
|
||||||
|
- 错误提示
|
||||||
|
|
||||||
|
### 常见错误
|
||||||
|
|
||||||
|
**缺少引号**:
|
||||||
|
```json
|
||||||
|
// ❌ 错误
|
||||||
|
{ env: { KEY: "value" } }
|
||||||
|
|
||||||
|
// ✅ 正确
|
||||||
|
{ "env": { "KEY": "value" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**多余逗号**:
|
||||||
|
```json
|
||||||
|
// ❌ 错误
|
||||||
|
{ "env": { "KEY": "value", } }
|
||||||
|
|
||||||
|
// ✅ 正确
|
||||||
|
{ "env": { "KEY": "value" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**未闭合括号**:
|
||||||
|
```json
|
||||||
|
// ❌ 错误
|
||||||
|
{ "env": { "KEY": "value" }
|
||||||
|
|
||||||
|
// ✅ 正确
|
||||||
|
{ "env": { "KEY": "value" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 保存与生效
|
||||||
|
|
||||||
|
1. 点击「保存」按钮
|
||||||
|
2. 如果是当前启用的供应商,配置立即写入 live 文件
|
||||||
|
3. 重启 CLI 工具生效
|
||||||
|
|
||||||
|
## 取消编辑
|
||||||
|
|
||||||
|
点击「取消」或按 `Esc` 键关闭编辑面板,所有修改都不会保存。
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 2.4 排序与复制
|
||||||
|
|
||||||
|
## 拖拽排序
|
||||||
|
|
||||||
|
通过拖拽调整供应商的显示顺序。
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 将鼠标移到供应商卡片左侧的 **≡** 拖拽手柄
|
||||||
|
2. 按住鼠标左键
|
||||||
|
3. 上下拖动到目标位置
|
||||||
|
4. 松开鼠标完成排序
|
||||||
|
|
||||||
|
### 排序用途
|
||||||
|
|
||||||
|
- **常用优先**:将常用的供应商放在列表顶部
|
||||||
|
- **故障转移顺序**:排序会影响故障转移队列的默认顺序
|
||||||
|
|
||||||
|
## 复制供应商
|
||||||
|
|
||||||
|
快速创建供应商的副本,适用于:
|
||||||
|
|
||||||
|
- 基于现有配置创建变体
|
||||||
|
- 备份当前配置
|
||||||
|
- 创建测试用配置
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 鼠标悬停在供应商卡片上,显示操作按钮
|
||||||
|
2. 点击「复制」按钮
|
||||||
|
3. 自动创建副本,名称后缀 `copy`
|
||||||
|
4. 编辑副本修改配置
|
||||||
|
|
||||||
|
### 复制内容
|
||||||
|
|
||||||
|
复制会创建完整的副本,包括:
|
||||||
|
|
||||||
|
| 内容 | 是否复制 |
|
||||||
|
|------|----------|
|
||||||
|
| 名称 | ✅ 复制(添加 `copy` 后缀) |
|
||||||
|
| 配置 | ✅ 完整复制 |
|
||||||
|
| 备注 | ✅ 复制 |
|
||||||
|
| 网站链接 | ✅ 复制 |
|
||||||
|
| 图标 | ✅ 复制 |
|
||||||
|
| 端点列表 | ✅ 复制 |
|
||||||
|
| 排序位置 | ✅ 插入到原供应商下方 |
|
||||||
|
|
||||||
|
### 复制后编辑
|
||||||
|
|
||||||
|
复制完成后,通常需要修改:
|
||||||
|
|
||||||
|
1. **名称**:改为有意义的名称
|
||||||
|
2. **API Key**:如果是不同账号
|
||||||
|
3. **端点**:如果是不同服务
|
||||||
|
|
||||||
|
## 删除供应商
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 鼠标悬停在供应商卡片上,显示操作按钮
|
||||||
|
2. 点击「删除」按钮
|
||||||
|
3. 确认删除
|
||||||
|
|
||||||
|
### 删除确认
|
||||||
|
|
||||||
|
删除前会弹出确认对话框,显示:
|
||||||
|
|
||||||
|
- 供应商名称
|
||||||
|
- 删除后无法恢复的提示
|
||||||
|
|
||||||
|
### 删除限制
|
||||||
|
|
||||||
|
- **当前启用的供应商**:可以删除,但建议先切换到其他供应商
|
||||||
|
- **统一供应商**:删除后,关联的应用配置也会被删除
|
||||||
|
|
||||||
|

|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# 2.5 用量查询
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
用量查询功能允许你配置自定义脚本,实时查询供应商的剩余额度、已用量等信息。
|
||||||
|
|
||||||
|
**使用场景**:
|
||||||
|
- 查看 API 账户剩余余额
|
||||||
|
- 监控套餐使用情况
|
||||||
|
- 多套餐额度汇总显示
|
||||||
|
|
||||||
|
## 打开配置
|
||||||
|
|
||||||
|
1. 鼠标悬停在供应商卡片上,显示操作按钮
|
||||||
|
2. 点击「用量查询」按钮(📊 图标)
|
||||||
|
3. 打开用量查询配置面板
|
||||||
|
|
||||||
|
## 启用用量查询
|
||||||
|
|
||||||
|
在配置面板顶部,开启「启用用量查询」开关。
|
||||||
|
|
||||||
|
## 预设模板
|
||||||
|
|
||||||
|
CC Switch 提供三种预设模板:
|
||||||
|
|
||||||
|
### 自定义模板
|
||||||
|
|
||||||
|
完全自定义请求和提取逻辑,适用于特殊 API 格式。
|
||||||
|
|
||||||
|
### 通用模板
|
||||||
|
|
||||||
|
适用于大多数标准 API 格式的供应商:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
({
|
||||||
|
request: {
|
||||||
|
url: "{{baseUrl}}/user/balance",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": "Bearer {{apiKey}}",
|
||||||
|
"User-Agent": "cc-switch/1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extractor: function(response) {
|
||||||
|
return {
|
||||||
|
isValid: response.is_active || true,
|
||||||
|
remaining: response.balance,
|
||||||
|
unit: "USD"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置参数**:
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| API Key | 用于认证的密钥(可选,留空则使用供应商配置的 Key) |
|
||||||
|
| Base URL | API 基础地址(可选,留空则使用供应商端点) |
|
||||||
|
|
||||||
|
### New API 模板
|
||||||
|
|
||||||
|
专为 New API 类型的中转服务设计:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
({
|
||||||
|
request: {
|
||||||
|
url: "{{baseUrl}}/api/user/self",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer {{accessToken}}",
|
||||||
|
"New-Api-User": "{{userId}}"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extractor: function (response) {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
return {
|
||||||
|
planName: response.data.group || "默认套餐",
|
||||||
|
remaining: response.data.quota / 500000,
|
||||||
|
used: response.data.used_quota / 500000,
|
||||||
|
total: (response.data.quota + response.data.used_quota) / 500000,
|
||||||
|
unit: "USD",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
invalidMessage: response.message || "查询失败"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置参数**:
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| Base URL | New API 服务地址 |
|
||||||
|
| Access Token | 访问令牌 |
|
||||||
|
| User ID | 用户 ID |
|
||||||
|
|
||||||
|
## 通用配置
|
||||||
|
|
||||||
|
### 超时时间
|
||||||
|
|
||||||
|
请求超时时间(秒),默认 10 秒。
|
||||||
|
|
||||||
|
### 自动查询间隔
|
||||||
|
|
||||||
|
自动刷新用量数据的间隔(分钟):
|
||||||
|
- 设为 `0` 表示禁用自动查询
|
||||||
|
- 范围:0-1440 分钟(最长 24 小时)
|
||||||
|
- 仅当供应商处于「当前启用」状态时生效
|
||||||
|
|
||||||
|
## 提取器返回格式
|
||||||
|
|
||||||
|
提取器函数需要返回包含以下字段的对象:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `isValid` | boolean | 否 | 账户是否有效,默认 true |
|
||||||
|
| `invalidMessage` | string | 否 | 无效时的提示信息 |
|
||||||
|
| `remaining` | number | 是 | 剩余额度 |
|
||||||
|
| `unit` | string | 是 | 单位(如 USD、CNY、次) |
|
||||||
|
| `planName` | string | 否 | 套餐名称(支持多套餐) |
|
||||||
|
| `total` | number | 否 | 总额度 |
|
||||||
|
| `used` | number | 否 | 已使用额度 |
|
||||||
|
| `extra` | object | 否 | 额外信息 |
|
||||||
|
|
||||||
|
## 测试脚本
|
||||||
|
|
||||||
|
配置完成后,点击「测试脚本」按钮验证:
|
||||||
|
|
||||||
|
1. 发送请求到配置的 URL
|
||||||
|
2. 执行提取器函数
|
||||||
|
3. 显示返回结果或错误信息
|
||||||
|
|
||||||
|
## 显示效果
|
||||||
|
|
||||||
|
配置成功后,供应商卡片上会显示:
|
||||||
|
|
||||||
|
- **单套餐**:直接显示剩余额度
|
||||||
|
- **多套餐**:显示套餐数量,点击展开查看详情
|
||||||
|
|
||||||
|
## 变量占位符
|
||||||
|
|
||||||
|
脚本中可使用以下占位符,运行时自动替换:
|
||||||
|
|
||||||
|
| 占位符 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `{{apiKey}}` | 配置的 API Key |
|
||||||
|
| `{{baseUrl}}` | 配置的 Base URL |
|
||||||
|
| `{{accessToken}}` | 配置的 Access Token(New API) |
|
||||||
|
| `{{userId}}` | 配置的 User ID(New API) |
|
||||||
|
|
||||||
|
## 常见供应商配置示例
|
||||||
|
|
||||||
|
### 故障排除
|
||||||
|
|
||||||
|
### 查询失败
|
||||||
|
|
||||||
|
**检查**:
|
||||||
|
1. API Key 是否正确
|
||||||
|
2. Base URL 是否正确
|
||||||
|
3. 网络是否可访问
|
||||||
|
4. 超时时间是否足够
|
||||||
|
|
||||||
|
### 返回数据为空
|
||||||
|
|
||||||
|
**检查**:
|
||||||
|
1. 提取器函数是否有 `return` 语句
|
||||||
|
2. 响应数据结构是否与提取器匹配
|
||||||
|
3. 使用「测试脚本」查看原始响应
|
||||||
|
|
||||||
|
### 格式化失败
|
||||||
|
|
||||||
|
脚本语法错误时,点击「格式化」按钮会提示错误位置。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 用量查询会消耗少量 API 请求配额
|
||||||
|
- 建议设置合理的自动查询间隔,避免频繁请求
|
||||||
|
- 敏感信息(API Key、Token)会安全存储在本地
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# 3.1 MCP 服务器管理
|
||||||
|
|
||||||
|
## 什么是 MCP
|
||||||
|
|
||||||
|
MCP (Model Context Protocol) 是一种协议,允许 AI 工具访问外部数据源和工具。通过 MCP 服务器,你可以让 AI:
|
||||||
|
|
||||||
|
- 访问文件系统
|
||||||
|
- 执行网络请求
|
||||||
|
- 查询数据库
|
||||||
|
- 调用外部 API
|
||||||
|
|
||||||
|
## 打开 MCP 面板
|
||||||
|
|
||||||
|
点击顶部导航栏的 **MCP** 按钮。
|
||||||
|
|
||||||
|
## 面板概览
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 添加 MCP 服务器
|
||||||
|
|
||||||
|
### 使用预设模板
|
||||||
|
|
||||||
|
1. 点击右上角 **+** 按钮
|
||||||
|
2. 在「预设」下拉框中选择模板
|
||||||
|
3. 根据需要修改配置
|
||||||
|
4. 点击「保存」
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 常用预设
|
||||||
|
|
||||||
|
| 预设 | 包名 | 功能说明 |
|
||||||
|
|------|------|----------|
|
||||||
|
| fetch | mcp-server-fetch | HTTP 请求工具,让 AI 能够获取网页内容 |
|
||||||
|
| time | @modelcontextprotocol/server-time | 时间工具,提供当前时间信息 |
|
||||||
|
| memory | @modelcontextprotocol/server-memory | 记忆工具,让 AI 能够存储和检索信息 |
|
||||||
|
| sequential-thinking | @modelcontextprotocol/server-sequential-thinking | 思维链工具,增强 AI 推理能力 |
|
||||||
|
| context7 | @upstash/context7-mcp | 文档搜索工具,查询技术文档 |
|
||||||
|
|
||||||
|
### 自定义配置
|
||||||
|
|
||||||
|
选择「自定义」后,需要填写:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 服务器 ID | 是 | 唯一标识符 |
|
||||||
|
| 名称 | 否 | 显示名称 |
|
||||||
|
| 描述 | 否 | 功能说明 |
|
||||||
|
| 传输类型 | 是 | stdio / http / sse |
|
||||||
|
| 命令 | 是* | stdio 类型必填 |
|
||||||
|
| 参数 | 否 | 命令行参数 |
|
||||||
|
| URL | 是* | http/sse 类型必填 |
|
||||||
|
| Headers | 否 | http/sse 类型的请求头 |
|
||||||
|
| 环境变量 | 否 | 传递给服务器的环境变量 |
|
||||||
|
|
||||||
|
## 传输类型
|
||||||
|
|
||||||
|
### stdio(标准输入输出)
|
||||||
|
|
||||||
|
最常用的类型,通过启动本地进程通信。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-fetch"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**要求**:
|
||||||
|
- 需要安装对应的命令(如 `uvx`、`npx`)
|
||||||
|
- 服务器程序需要在 PATH 中
|
||||||
|
|
||||||
|
### http
|
||||||
|
|
||||||
|
通过 HTTP 协议与远程服务器通信。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8080/mcp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### sse(Server-Sent Events)
|
||||||
|
|
||||||
|
通过 SSE 协议与服务器通信,支持实时推送。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8080/sse"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 应用绑定
|
||||||
|
|
||||||
|
每个 MCP 服务器可以独立控制启用的应用。
|
||||||
|
|
||||||
|
### 开关说明
|
||||||
|
|
||||||
|
| 开关 | 作用 | 配置文件路径 |
|
||||||
|
|------|------|--------------|
|
||||||
|
| Claude | 同步到 Claude Code | `~/.claude.json` 的 `mcpServers` |
|
||||||
|
| Codex | 同步到 Codex | `~/.codex/config.toml` 的 `[mcp_servers]` |
|
||||||
|
| Gemini | 同步到 Gemini CLI | `~/.gemini/settings.json` 的 `mcpServers` |
|
||||||
|
| OpenCode | 同步到 OpenCode | `~/.opencode/config.json` 的 `mcpServers` |
|
||||||
|
|
||||||
|
### 开关实现机制
|
||||||
|
|
||||||
|
当开启某个应用的开关时,CC Switch 会:
|
||||||
|
|
||||||
|
1. **更新数据库**:将服务器的 `apps.claude/codex/gemini/opencode` 状态设为 `true`
|
||||||
|
2. **同步到 Live 配置**:将服务器配置写入对应应用的配置文件
|
||||||
|
3. **即时生效**:下次启动 CLI 工具时自动加载新的 MCP 服务器
|
||||||
|
|
||||||
|
当关闭某个应用的开关时,CC Switch 会:
|
||||||
|
|
||||||
|
1. **更新数据库**:将对应应用状态设为 `false`
|
||||||
|
2. **从 Live 配置移除**:从应用配置文件中删除该服务器
|
||||||
|
3. **即时生效**:下次启动 CLI 工具时不再加载该 MCP 服务器
|
||||||
|
|
||||||
|
### 同步条件
|
||||||
|
|
||||||
|
MCP 服务器同步仅在对应应用已安装时执行:
|
||||||
|
|
||||||
|
- **Claude**:需存在 `~/.claude/` 目录或 `~/.claude.json` 文件
|
||||||
|
- **Codex**:需存在 `~/.codex/` 目录
|
||||||
|
- **Gemini**:需存在 `~/.gemini/` 目录
|
||||||
|
- **OpenCode**:需存在 `~/.opencode/` 目录
|
||||||
|
|
||||||
|
> 💡 **提示**:如果某个 CLI 工具未安装,开启对应开关不会报错,但配置不会写入。
|
||||||
|
|
||||||
|
关闭开关后,配置会从文件中移除。
|
||||||
|
|
||||||
|
## 编辑服务器
|
||||||
|
|
||||||
|
1. 点击服务器行右侧的「编辑」按钮
|
||||||
|
2. 修改配置
|
||||||
|
3. 点击「保存」
|
||||||
|
|
||||||
|
修改会立即同步到已启用的应用配置文件。
|
||||||
|
|
||||||
|
## 删除服务器
|
||||||
|
|
||||||
|
1. 点击服务器行右侧的「删除」按钮
|
||||||
|
2. 确认删除
|
||||||
|
|
||||||
|
删除后,配置会从所有应用的配置文件中移除。
|
||||||
|
|
||||||
|
## 导入现有配置
|
||||||
|
|
||||||
|
如果你已经在 CLI 工具中配置了 MCP 服务器,可以导入到 CC Switch:
|
||||||
|
|
||||||
|
1. 点击「导入」按钮
|
||||||
|
2. 选择要导入的应用(Claude/Codex/Gemini/OpenCode)
|
||||||
|
3. CC Switch 会读取现有配置并导入
|
||||||
|
|
||||||
|
## 配置文件格式
|
||||||
|
|
||||||
|
### Claude (`~/.claude.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mcp-fetch": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-fetch"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codex (`~/.codex/config.toml`)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mcp_servers.mcp-fetch]
|
||||||
|
command = "uvx"
|
||||||
|
args = ["mcp-server-fetch"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemini (`~/.gemini/settings.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mcp-fetch": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-fetch"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 服务器启动失败
|
||||||
|
|
||||||
|
检查:
|
||||||
|
- 命令是否正确安装(如 `uvx`)
|
||||||
|
- 命令是否在 PATH 中
|
||||||
|
- 参数是否正确
|
||||||
|
|
||||||
|
### 配置不生效
|
||||||
|
|
||||||
|
确保:
|
||||||
|
- 对应应用的开关已开启
|
||||||
|
- 重启了 CLI 工具
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# 3.2 Prompts 提示词管理
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
Prompts 功能用于管理系统提示词预设。系统提示词会影响 AI 的行为和回复风格。
|
||||||
|
|
||||||
|
通过 CC Switch,你可以:
|
||||||
|
|
||||||
|
- 创建多个提示词预设
|
||||||
|
- 快速切换不同场景的提示词
|
||||||
|
- 跨设备同步提示词配置
|
||||||
|
|
||||||
|
## 打开 Prompts 面板
|
||||||
|
|
||||||
|
点击顶部导航栏的 **Prompts** 按钮。
|
||||||
|
|
||||||
|
## 面板概览
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 创建预设
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 点击右上角 **+** 按钮
|
||||||
|
2. 输入预设名称
|
||||||
|
3. 在 Markdown 编辑器中编写提示词
|
||||||
|
4. 点击「保存」
|
||||||
|
|
||||||
|
### Markdown 编辑器
|
||||||
|
|
||||||
|
编辑器提供:
|
||||||
|
|
||||||
|
- 语法高亮
|
||||||
|
- 实时预览
|
||||||
|
- 常用格式快捷键
|
||||||
|
|
||||||
|
### 提示词编写建议
|
||||||
|
|
||||||
|
**结构化格式**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 角色定义
|
||||||
|
|
||||||
|
你是一个专业的代码审查专家。
|
||||||
|
|
||||||
|
## 核心能力
|
||||||
|
|
||||||
|
- 代码质量分析
|
||||||
|
- 性能优化建议
|
||||||
|
- 安全漏洞检测
|
||||||
|
|
||||||
|
## 回复风格
|
||||||
|
|
||||||
|
- 简洁明了
|
||||||
|
- 提供具体示例
|
||||||
|
- 给出改进建议
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 不要修改业务逻辑
|
||||||
|
- 保持代码风格一致
|
||||||
|
```
|
||||||
|
|
||||||
|
## 激活预设
|
||||||
|
|
||||||
|
### 操作方式
|
||||||
|
|
||||||
|
点击预设项的开关按钮,切换启用状态。
|
||||||
|
|
||||||
|
### 单一激活
|
||||||
|
|
||||||
|
同一时间只能激活一个预设。激活新预设时,之前的预设会自动停用。
|
||||||
|
|
||||||
|
### 同步目标
|
||||||
|
|
||||||
|
激活后,提示词会写入对应应用的文件:
|
||||||
|
|
||||||
|
| 应用 | 文件路径 |
|
||||||
|
|------|----------|
|
||||||
|
| Claude | `~/.claude/CLAUDE.md` |
|
||||||
|
| Codex | `~/.codex/AGENTS.md` |
|
||||||
|
| Gemini | `~/.gemini/GEMINI.md` |
|
||||||
|
| OpenCode | `~/.opencode/AGENTS.md` |
|
||||||
|
|
||||||
|
## 编辑预设
|
||||||
|
|
||||||
|
1. 点击预设项的「编辑」按钮
|
||||||
|
2. 修改名称或内容
|
||||||
|
3. 点击「保存」
|
||||||
|
|
||||||
|
如果编辑的是当前激活的预设,保存后会立即同步到配置文件。
|
||||||
|
|
||||||
|
## 删除预设
|
||||||
|
|
||||||
|
1. 点击预设项的「删除」按钮
|
||||||
|
2. 确认删除
|
||||||
|
|
||||||
|
已启用的预设不允许删除,需先停用后再删除。
|
||||||
|
|
||||||
|
## 智能回填
|
||||||
|
|
||||||
|
CC Switch 提供智能回填保护机制,确保你的手动修改不会丢失。
|
||||||
|
|
||||||
|
### 工作原理
|
||||||
|
|
||||||
|
1. 切换预设前,自动读取当前配置文件内容
|
||||||
|
2. 比较文件内容与数据库中的预设
|
||||||
|
3. 如果内容不同,说明用户手动修改过
|
||||||
|
4. 将手动修改的内容保存到当前预设
|
||||||
|
5. 然后再切换到新预设
|
||||||
|
|
||||||
|
### 保护场景
|
||||||
|
|
||||||
|
| 场景 | 处理方式 |
|
||||||
|
|------|----------|
|
||||||
|
| CLI 中直接编辑 `CLAUDE.md` | 修改自动保存到当前预设 |
|
||||||
|
| 外部编辑器修改配置文件 | 修改自动保存到当前预设 |
|
||||||
|
| 切换到其他预设 | 先保存当前修改,再切换 |
|
||||||
|
|
||||||
|
### 技术细节
|
||||||
|
|
||||||
|
回填机制在以下时机触发:
|
||||||
|
|
||||||
|
- **切换预设时**:保存当前 live 文件内容到当前预设
|
||||||
|
- **编辑当前预设时**:从 live 文件读取最新内容
|
||||||
|
- **首次启动时**:自动导入现有 live 文件内容
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- 回填仅在切换到不同预设时触发
|
||||||
|
- 如果当前没有激活的预设,不会触发回填
|
||||||
|
- 回填失败不会影响切换流程
|
||||||
|
|
||||||
|
## 跨应用使用
|
||||||
|
|
||||||
|
Prompts 是按应用分开管理的:
|
||||||
|
|
||||||
|
- 切换到 Claude 时,显示 Claude 的预设
|
||||||
|
- 切换到 Codex 时,显示 Codex 的预设
|
||||||
|
- 切换到 Gemini 时,显示 Gemini 的预设
|
||||||
|
- 切换到 OpenCode 时,显示 OpenCode 的预设
|
||||||
|
|
||||||
|
如需在多个应用使用相同的提示词,需要分别创建。
|
||||||
|
|
||||||
|
## 导入导出
|
||||||
|
|
||||||
|
### 通过深度链接分享
|
||||||
|
|
||||||
|
可以生成深度链接分享预设:
|
||||||
|
|
||||||
|
```
|
||||||
|
ccswitch://import/prompt?data=<base64编码的预设>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通过配置导出
|
||||||
|
|
||||||
|
导出配置时会包含所有预设,导入后可恢复。
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# 3.3 Skills 技能管理
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
Skills 是可复用的能力扩展,让 AI 工具获得特定领域的专业能力。
|
||||||
|
|
||||||
|
技能以文件夹形式存在,包含:
|
||||||
|
- 提示词模板
|
||||||
|
- 工具定义
|
||||||
|
- 示例代码
|
||||||
|
|
||||||
|
## 支持的应用
|
||||||
|
|
||||||
|
Skills 功能支持所有四种应用:
|
||||||
|
- **Claude Code**
|
||||||
|
- **Codex**
|
||||||
|
- **Gemini CLI**
|
||||||
|
- **OpenCode**
|
||||||
|
|
||||||
|
## 打开 Skills 页面
|
||||||
|
|
||||||
|
点击顶部导航栏的 **Skills** 按钮。
|
||||||
|
|
||||||
|
> 注意:Skills 按钮在所有应用模式下均可见。
|
||||||
|
|
||||||
|
## 页面概览
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 发现技能
|
||||||
|
|
||||||
|
### 预配置仓库
|
||||||
|
|
||||||
|
CC Switch 预配置了以下 GitHub 仓库:
|
||||||
|
|
||||||
|
| 仓库 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| Anthropic 官方 | Anthropic 提供的官方技能 |
|
||||||
|
| ComposioHQ | 社区维护的技能集合 |
|
||||||
|
| 社区精选 | 精选的高质量技能 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 搜索过滤
|
||||||
|
|
||||||
|
CC Switch 提供强大的搜索和过滤功能:
|
||||||
|
|
||||||
|
#### 搜索框
|
||||||
|
|
||||||
|
- 支持按技能名称搜索
|
||||||
|
- 支持按技能描述搜索
|
||||||
|
- 支持按目录名称搜索
|
||||||
|
- 实时过滤,输入即搜索
|
||||||
|
|
||||||
|
#### 状态过滤
|
||||||
|
|
||||||
|
使用下拉菜单按安装状态过滤:
|
||||||
|
|
||||||
|
| 选项 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 全部 | 显示所有技能 |
|
||||||
|
| 已安装 | 仅显示已安装的技能 |
|
||||||
|
| 未安装 | 仅显示未安装的技能 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 组合使用
|
||||||
|
|
||||||
|
搜索和过滤可以组合使用:
|
||||||
|
- 先选择「已安装」过滤
|
||||||
|
- 再输入关键词搜索
|
||||||
|
- 结果显示匹配数量
|
||||||
|
|
||||||
|
### 刷新列表
|
||||||
|
|
||||||
|
点击「刷新」按钮重新扫描仓库,获取最新技能。
|
||||||
|
|
||||||
|
## 安装技能
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 找到要安装的技能卡片
|
||||||
|
2. 点击「安装」按钮
|
||||||
|
3. 等待安装完成
|
||||||
|
|
||||||
|
### 安装位置
|
||||||
|
|
||||||
|
| 应用 | 安装目录 |
|
||||||
|
|------|----------|
|
||||||
|
| Claude | `~/.claude/skills/` |
|
||||||
|
| Codex | `~/.codex/skills/` |
|
||||||
|
| Gemini | `~/.gemini/skills/` |
|
||||||
|
| OpenCode | `~/.opencode/skills/` |
|
||||||
|
|
||||||
|
### 安装内容
|
||||||
|
|
||||||
|
安装会将技能文件夹复制到本地:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.claude/skills/
|
||||||
|
└── skill-name/
|
||||||
|
├── README.md
|
||||||
|
├── prompt.md
|
||||||
|
└── tools/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 卸载技能
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 找到已安装的技能卡片
|
||||||
|
2. 点击「卸载」按钮
|
||||||
|
3. 确认卸载
|
||||||
|
|
||||||
|
### 卸载效果
|
||||||
|
|
||||||
|
- 删除本地技能文件夹
|
||||||
|
- 更新安装状态
|
||||||
|
|
||||||
|
## 仓库管理
|
||||||
|
|
||||||
|
### 打开仓库管理
|
||||||
|
|
||||||
|
点击页面顶部的「仓库管理」按钮。
|
||||||
|
|
||||||
|
### 添加自定义仓库
|
||||||
|
|
||||||
|
1. 点击「添加仓库」
|
||||||
|
2. 填写仓库信息:
|
||||||
|
- Owner:GitHub 用户名或组织名
|
||||||
|
- Name:仓库名称
|
||||||
|
- Branch:分支名(默认 main)
|
||||||
|
- Subdirectory:技能所在子目录(可选)
|
||||||
|
3. 点击「添加」
|
||||||
|
|
||||||
|
### 仓库格式
|
||||||
|
|
||||||
|
```
|
||||||
|
https://github.com/{owner}/{name}/tree/{branch}/{subdirectory}
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```
|
||||||
|
Owner: anthropics
|
||||||
|
Name: claude-skills
|
||||||
|
Branch: main
|
||||||
|
Subdirectory: skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除仓库
|
||||||
|
|
||||||
|
1. 在仓库列表中找到要删除的仓库
|
||||||
|
2. 点击「删除」按钮
|
||||||
|
3. 确认删除
|
||||||
|
|
||||||
|
删除仓库后,该仓库的技能不会从列表中消失,但无法再更新。
|
||||||
|
|
||||||
|
## 技能卡片信息
|
||||||
|
|
||||||
|
每个技能卡片显示:
|
||||||
|
|
||||||
|
| 信息 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 名称 | 技能名称 |
|
||||||
|
| 描述 | 功能说明 |
|
||||||
|
| 来源 | 所属仓库 |
|
||||||
|
| 状态 | 已安装 / 未安装 |
|
||||||
|
|
||||||
|
## 技能更新
|
||||||
|
|
||||||
|
目前不支持自动更新。如需更新技能:
|
||||||
|
|
||||||
|
1. 卸载现有技能
|
||||||
|
2. 刷新列表
|
||||||
|
3. 重新安装
|
||||||
|
|
||||||
|
### 技能列表为空
|
||||||
|
|
||||||
|
可能原因:
|
||||||
|
- 网络问题,无法访问 GitHub
|
||||||
|
- 仓库配置错误
|
||||||
|
|
||||||
|
解决方法:
|
||||||
|
- 检查网络连接
|
||||||
|
- 点击「刷新」重试
|
||||||
|
- 检查仓库配置
|
||||||
|
|
||||||
|
### 安装失败
|
||||||
|
|
||||||
|
可能原因:
|
||||||
|
- 网络问题
|
||||||
|
- 磁盘空间不足
|
||||||
|
- 权限问题
|
||||||
|
|
||||||
|
解决方法:
|
||||||
|
- 检查网络连接
|
||||||
|
- 检查磁盘空间
|
||||||
|
- 检查目录权限
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# 4.1 代理服务
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
代理服务在本地启动一个 HTTP 代理,所有 API 请求都通过代理转发。
|
||||||
|
|
||||||
|
**主要用途**:
|
||||||
|
- 记录请求日志
|
||||||
|
- 统计 API 用量
|
||||||
|
- 支持故障转移
|
||||||
|
- 集中管理多个应用的请求
|
||||||
|
|
||||||
|
## 启动代理
|
||||||
|
|
||||||
|
### 方式一:主界面开关
|
||||||
|
|
||||||
|
点击主界面顶部的 **代理开关** 按钮。
|
||||||
|
|
||||||
|
开关状态:
|
||||||
|
- 🔴 白色:代理未运行
|
||||||
|
- 🟢 绿色:代理运行中
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 方式二:设置页面
|
||||||
|
|
||||||
|
1. 打开「设置 → 高级 → 代理服务」
|
||||||
|
2. 点击右上角的开关
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 代理配置
|
||||||
|
|
||||||
|
### 基础配置
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 监听地址 | 代理绑定的 IP 地址 | `127.0.0.1` |
|
||||||
|
| 监听端口 | 代理监听的端口 | `15721` |
|
||||||
|
| 启用日志 | 是否记录请求日志 | 开启 |
|
||||||
|
|
||||||
|
### 修改配置
|
||||||
|
|
||||||
|
1. **停止代理服务**(必须先停止)
|
||||||
|
2. 修改监听地址或端口
|
||||||
|
3. 点击「保存」
|
||||||
|
4. 重新启动代理
|
||||||
|
|
||||||
|
> ⚠️ 修改地址/端口需要先停止代理服务
|
||||||
|
|
||||||
|
### 监听地址说明
|
||||||
|
|
||||||
|
| 地址 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `127.0.0.1` | 仅本机可访问(推荐) |
|
||||||
|
| `0.0.0.0` | 允许局域网访问 |
|
||||||
|
|
||||||
|
## 运行状态
|
||||||
|
|
||||||
|
代理运行时,面板显示以下信息:
|
||||||
|
|
||||||
|
### 服务地址
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:15721
|
||||||
|
```
|
||||||
|
|
||||||
|
点击「复制」按钮可复制地址。
|
||||||
|
|
||||||
|
### 当前供应商
|
||||||
|
|
||||||
|
显示各应用当前使用的供应商:
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude: PackyCode
|
||||||
|
Codex: AIGoCode
|
||||||
|
Gemini: Google 官方
|
||||||
|
```
|
||||||
|
|
||||||
|
### 统计数据
|
||||||
|
|
||||||
|
| 指标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 活跃连接 | 当前正在处理的请求数 |
|
||||||
|
| 总请求数 | 启动以来的总请求数 |
|
||||||
|
| 成功率 | 请求成功的百分比(>90% 绿色,≤90% 黄色) |
|
||||||
|
| 运行时间 | 代理已运行的时长 |
|
||||||
|
|
||||||
|
### 故障转移队列
|
||||||
|
|
||||||
|
代理面板会按应用类型显示故障转移队列:
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude
|
||||||
|
├── 1. PackyCode [当前使用] ●
|
||||||
|
├── 2. AIGoCode ●
|
||||||
|
└── 3. 备用供应商 ○
|
||||||
|
|
||||||
|
Codex
|
||||||
|
├── 1. AIGoCode [当前使用] ●
|
||||||
|
└── 2. 备用供应商 ●
|
||||||
|
```
|
||||||
|
|
||||||
|
队列说明:
|
||||||
|
- 数字表示优先级顺序
|
||||||
|
- 「当前使用」标签表示正在使用的供应商
|
||||||
|
- 健康徽章显示供应商状态:
|
||||||
|
- 🟢 绿色:健康(连续失败 0 次)
|
||||||
|
- 🟡 黄色:降级(连续失败 1-2 次)
|
||||||
|
- 🔴 红色:不健康(连续失败 ≥3 次)
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
### 请求流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CLI as CLI 工具 (Claude)
|
||||||
|
participant Proxy as 本地代理 (CC Switch)
|
||||||
|
participant API as API 供应商 (Anthropic)
|
||||||
|
participant DB as 数据存储 (Logger)
|
||||||
|
|
||||||
|
CLI->>Proxy: 发送 API 请求
|
||||||
|
Proxy->>DB: 记录请求日志/统计用量
|
||||||
|
Proxy->>API: 转发请求
|
||||||
|
API-->>Proxy: 返回响应
|
||||||
|
Proxy-->>CLI: 返回响应
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置修改
|
||||||
|
|
||||||
|
启动代理并开启应用接管后,CC Switch 会修改应用配置:
|
||||||
|
|
||||||
|
**Claude**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Codex**:
|
||||||
|
```toml
|
||||||
|
base_url = "http://127.0.0.1:15721/v1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gemini**:
|
||||||
|
```
|
||||||
|
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||||
|
```
|
||||||
|
|
||||||
|
## 停止代理
|
||||||
|
|
||||||
|
### 方式一:主界面开关
|
||||||
|
|
||||||
|
点击代理开关按钮关闭。
|
||||||
|
|
||||||
|
### 方式二:设置页面
|
||||||
|
|
||||||
|
在代理服务面板中关闭开关。
|
||||||
|
|
||||||
|
### 停止后的处理
|
||||||
|
|
||||||
|
停止代理时,CC Switch 会:
|
||||||
|
|
||||||
|
1. 恢复应用配置到原始状态
|
||||||
|
2. 保存请求日志
|
||||||
|
3. 关闭所有连接
|
||||||
|
|
||||||
|
## 日志记录
|
||||||
|
|
||||||
|
### 开启日志
|
||||||
|
|
||||||
|
在代理面板中开启「启用日志」开关。
|
||||||
|
|
||||||
|
### 日志内容
|
||||||
|
|
||||||
|
每条请求记录包含:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 时间 | 请求时间 |
|
||||||
|
| 应用 | Claude/Codex/Gemini/OpenCode |
|
||||||
|
| 供应商 | 使用的供应商 |
|
||||||
|
| 模型 | 请求的模型 |
|
||||||
|
| Token | 输入/输出 token 数 |
|
||||||
|
| 延迟 | 请求耗时 |
|
||||||
|
| 状态 | 成功/失败 |
|
||||||
|
|
||||||
|
### 查看日志
|
||||||
|
|
||||||
|
在「设置 → 用量」Tab 中查看请求日志。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 端口被占用
|
||||||
|
|
||||||
|
错误信息:`Address already in use`
|
||||||
|
|
||||||
|
解决方法:
|
||||||
|
1. 更换端口(如 5001)
|
||||||
|
2. 或关闭占用端口的程序
|
||||||
|
|
||||||
|
### 代理启动失败
|
||||||
|
|
||||||
|
检查:
|
||||||
|
- 端口是否被占用
|
||||||
|
- 是否有足够权限
|
||||||
|
- 防火墙是否阻止
|
||||||
|
|
||||||
|
### 请求超时
|
||||||
|
|
||||||
|
可能原因:
|
||||||
|
- 网络问题
|
||||||
|
- 供应商服务器问题
|
||||||
|
- 代理配置错误
|
||||||
|
|
||||||
|
解决方法:
|
||||||
|
- 检查网络连接
|
||||||
|
- 尝试直接访问供应商 API
|
||||||
|
- 检查供应商配置
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# 4.2 应用接管
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
应用接管是指让 CC Switch 代理接管特定应用的 API 请求。
|
||||||
|
|
||||||
|
开启接管后:
|
||||||
|
- 应用的 API 请求会通过本地代理转发
|
||||||
|
- 可以记录请求日志和统计用量
|
||||||
|
- 可以使用故障转移功能
|
||||||
|
|
||||||
|
## 前提条件
|
||||||
|
|
||||||
|
使用应用接管功能前,需要先启动代理服务。
|
||||||
|
|
||||||
|
## 开启接管
|
||||||
|
|
||||||
|
### 操作位置
|
||||||
|
|
||||||
|
设置 → 高级 → 代理服务 → 应用接管区域
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 确保代理服务已启动
|
||||||
|
2. 找到「应用接管」区域
|
||||||
|
3. 为需要的应用开启开关
|
||||||
|
|
||||||
|
### 接管开关
|
||||||
|
|
||||||
|
| 开关 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| Claude 接管 | 接管 Claude Code 的请求 |
|
||||||
|
| Codex 接管 | 接管 Codex 的请求 |
|
||||||
|
| Gemini 接管 | 接管 Gemini CLI 的请求 |
|
||||||
|
| OpenCode 接管 | 接管 OpenCode 的请求 |
|
||||||
|
|
||||||
|
可以同时开启多个应用的接管。
|
||||||
|
|
||||||
|
## 接管原理
|
||||||
|
|
||||||
|
### 配置修改
|
||||||
|
|
||||||
|
开启接管后,CC Switch 会修改应用的配置文件,将 API 端点指向本地代理。
|
||||||
|
|
||||||
|
**Claude 配置变更**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 接管前
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接管后
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Codex 配置变更**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# 接管前
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
|
||||||
|
# 接管后
|
||||||
|
base_url = "http://127.0.0.1:15721/v1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gemini 配置变更**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 接管前
|
||||||
|
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||||
|
|
||||||
|
# 接管后
|
||||||
|
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求转发
|
||||||
|
|
||||||
|
代理收到请求后:
|
||||||
|
|
||||||
|
1. 识别请求来源(Claude/Codex/Gemini/OpenCode)
|
||||||
|
2. 查找该应用当前启用的供应商
|
||||||
|
3. 将请求转发到供应商的实际端点
|
||||||
|
4. 记录请求日志
|
||||||
|
5. 返回响应给应用
|
||||||
|
|
||||||
|
## 接管状态指示
|
||||||
|
|
||||||
|
### 主界面指示
|
||||||
|
|
||||||
|
开启接管后,主界面会有以下变化:
|
||||||
|
|
||||||
|
- **代理 Logo 颜色**:从无色变为绿色
|
||||||
|
- **供应商卡片**:当前活跃的供应商显示绿色边框
|
||||||
|
|
||||||
|
### 供应商卡片状态
|
||||||
|
|
||||||
|
| 状态 | 边框颜色 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 当前启用 | 蓝色 | 配置文件中的供应商(非代理模式) |
|
||||||
|
| 代理活跃 | 绿色 | 代理实际使用的供应商 |
|
||||||
|
| 普通 | 默认 | 未使用的供应商 |
|
||||||
|
|
||||||
|
## 关闭接管
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 在代理面板中关闭对应应用的接管开关
|
||||||
|
2. 或直接停止代理服务
|
||||||
|
|
||||||
|
### 配置恢复
|
||||||
|
|
||||||
|
关闭接管时,CC Switch 会:
|
||||||
|
|
||||||
|
1. 将应用配置恢复到接管前的状态
|
||||||
|
2. 保存当前的请求日志
|
||||||
|
|
||||||
|
## 接管与供应商切换
|
||||||
|
|
||||||
|
### 接管模式下切换供应商
|
||||||
|
|
||||||
|
在接管模式下切换供应商:
|
||||||
|
|
||||||
|
1. 在主界面点击供应商的「启用」按钮
|
||||||
|
2. 代理立即使用新供应商转发请求
|
||||||
|
3. **无需重启 CLI 工具**
|
||||||
|
|
||||||
|
这是接管模式的一大优势:切换供应商即时生效。
|
||||||
|
|
||||||
|
### 非接管模式下切换
|
||||||
|
|
||||||
|
在非接管模式下切换供应商:
|
||||||
|
|
||||||
|
1. 修改配置文件
|
||||||
|
2. 需要重启 CLI 工具才能生效
|
||||||
|
|
||||||
|
## 多应用接管
|
||||||
|
|
||||||
|
可以同时接管多个应用,每个应用独立管理:
|
||||||
|
|
||||||
|
- 独立的供应商配置
|
||||||
|
- 独立的故障转移队列
|
||||||
|
- 独立的请求统计
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 场景一:用量监控
|
||||||
|
|
||||||
|
开启接管 + 日志记录,监控 API 使用情况。
|
||||||
|
|
||||||
|
### 场景二:快速切换
|
||||||
|
|
||||||
|
开启接管后,切换供应商无需重启 CLI 工具。
|
||||||
|
|
||||||
|
### 场景三:故障转移
|
||||||
|
|
||||||
|
开启接管是使用故障转移功能的前提。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
### 性能影响
|
||||||
|
|
||||||
|
代理会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
|
||||||
|
|
||||||
|
### 网络要求
|
||||||
|
|
||||||
|
接管模式下,CLI 工具需要能够访问本地代理地址。
|
||||||
|
|
||||||
|
### 配置备份
|
||||||
|
|
||||||
|
开启接管前,CC Switch 会备份原始配置,关闭时恢复。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 接管后请求失败
|
||||||
|
|
||||||
|
检查:
|
||||||
|
- 代理服务是否正常运行
|
||||||
|
- 供应商配置是否正确
|
||||||
|
- 网络是否正常
|
||||||
|
|
||||||
|
### 关闭接管后配置未恢复
|
||||||
|
|
||||||
|
可能原因:
|
||||||
|
- 代理异常退出
|
||||||
|
- 配置文件被其他程序修改
|
||||||
|
|
||||||
|
解决方法:
|
||||||
|
- 手动编辑供应商,重新保存
|
||||||
|
- 或重新启用再关闭接管
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# 4.3 故障转移
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
故障转移功能在主供应商请求失败时,自动切换到备用供应商,确保服务不中断。
|
||||||
|
|
||||||
|
**适用场景**:
|
||||||
|
- 供应商服务不稳定
|
||||||
|
- 需要高可用性
|
||||||
|
- 长时间运行的任务
|
||||||
|
|
||||||
|
## 前提条件
|
||||||
|
|
||||||
|
使用故障转移功能需要:
|
||||||
|
|
||||||
|
1. ✅ 启动代理服务
|
||||||
|
2. ✅ 开启应用接管
|
||||||
|
3. ✅ 配置故障转移队列
|
||||||
|
4. ✅ 开启自动故障转移
|
||||||
|
|
||||||
|
## 配置故障转移队列
|
||||||
|
|
||||||
|
### 打开配置页面
|
||||||
|
|
||||||
|
设置 → 高级 → 故障转移
|
||||||
|
|
||||||
|
### 选择应用
|
||||||
|
|
||||||
|
页面顶部有四个 Tab:
|
||||||
|
- Claude
|
||||||
|
- Codex
|
||||||
|
- Gemini
|
||||||
|
- OpenCode
|
||||||
|
|
||||||
|
选择要配置的应用。
|
||||||
|
|
||||||
|
### 添加备用供应商
|
||||||
|
|
||||||
|
1. 在「故障转移队列」区域
|
||||||
|
2. 点击「添加供应商」
|
||||||
|
3. 从下拉列表选择供应商
|
||||||
|
4. 供应商会添加到队列末尾
|
||||||
|
|
||||||
|
### 调整优先级
|
||||||
|
|
||||||
|
拖拽供应商调整顺序:
|
||||||
|
- 序号越小,优先级越高
|
||||||
|
- 主供应商失败后,按顺序尝试备用供应商
|
||||||
|
|
||||||
|
### 移除供应商
|
||||||
|
|
||||||
|
点击供应商右侧的「移除」按钮。
|
||||||
|
|
||||||
|
## 主界面快捷操作
|
||||||
|
|
||||||
|
当代理和故障转移都开启时,供应商卡片会显示故障转移开关。
|
||||||
|
|
||||||
|
### 添加到队列
|
||||||
|
|
||||||
|
1. 找到供应商卡片
|
||||||
|
2. 开启故障转移开关
|
||||||
|
3. 供应商自动添加到队列
|
||||||
|
|
||||||
|
### 从队列移除
|
||||||
|
|
||||||
|
1. 关闭供应商卡片的故障转移开关
|
||||||
|
2. 供应商从队列中移除
|
||||||
|
|
||||||
|
## 开启自动故障转移
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 在故障转移配置页面
|
||||||
|
2. 开启「自动故障转移」开关
|
||||||
|
|
||||||
|
### 开关说明
|
||||||
|
|
||||||
|
| 状态 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 关闭 | 仅记录失败,不自动切换 |
|
||||||
|
| 开启 | 失败时自动切换到下一个供应商 |
|
||||||
|
|
||||||
|
## 故障转移流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Start[请求到达代理] --> Send[发送到当前供应商]
|
||||||
|
Send --> CheckSuccess{成功?}
|
||||||
|
CheckSuccess -- 是 --> Return[返回响应]
|
||||||
|
CheckSuccess -- 否 --> LogFail[记录失败]
|
||||||
|
LogFail --> CheckCircuit{检查熔断状态}
|
||||||
|
CheckCircuit -- 熔断 --> Skip[跳过此供应商]
|
||||||
|
CheckCircuit -- 未熔断 --> IncFail[增加失败计数]
|
||||||
|
Skip --> Next{队列中下一个?}
|
||||||
|
IncFail --> Next
|
||||||
|
Next -- 有 --> Switch[切换供应商]
|
||||||
|
Switch --> Retry[重试请求]
|
||||||
|
Retry --> Send
|
||||||
|
Next -- 无 --> Error[返回错误]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 熔断器配置
|
||||||
|
|
||||||
|
熔断器防止频繁重试失败的供应商。
|
||||||
|
|
||||||
|
### 配置项
|
||||||
|
|
||||||
|
不同应用有独立的默认配置。以下为通用默认值,Claude 有独立的宽松配置。
|
||||||
|
|
||||||
|
| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |
|
||||||
|
|------|------|--------|--------|------|
|
||||||
|
| 失败阈值 | 连续失败多少次触发熔断 | 4 | 8 | 1-20 |
|
||||||
|
| 恢复成功阈值 | 半开状态下成功多少次后关闭熔断器 | 2 | 3 | 1-10 |
|
||||||
|
| 恢复等待时间 | 熔断后多久尝试恢复(秒) | 60 | 90 | 0-300 |
|
||||||
|
| 错误率阈值 | 错误率超过此值时打开熔断器 | 60% | 70% | 0-100% |
|
||||||
|
| 最小请求数 | 计算错误率前的最小请求数 | 10 | 15 | 5-100 |
|
||||||
|
|
||||||
|
> 💡 Claude 由于请求耗时较长,默认配置更为宽松,容忍更多失败次数。
|
||||||
|
|
||||||
|
### 超时配置
|
||||||
|
|
||||||
|
| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |
|
||||||
|
|------|------|--------|--------|------|
|
||||||
|
| 流式首字节超时 | 等待首个数据块的最大时间(秒) | 60 | 90 | 1-120 |
|
||||||
|
| 流式静默超时 | 数据块之间的最大间隔(秒) | 120 | 180 | 60-600(填 0 禁用) |
|
||||||
|
| 非流式超时 | 非流式请求的总超时时间(秒) | 600 | 600 | 60-1200 |
|
||||||
|
|
||||||
|
### 重试配置
|
||||||
|
|
||||||
|
| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |
|
||||||
|
|------|------|--------|--------|------|
|
||||||
|
| 最大重试次数 | 请求失败时的重试次数 | 3 | 6 | 0-10 |
|
||||||
|
|
||||||
|
> 💡 Gemini 的默认最大重试次数为 5。
|
||||||
|
|
||||||
|
### 熔断状态
|
||||||
|
|
||||||
|
| 状态 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 关闭 | 正常状态,允许请求 |
|
||||||
|
| 开启 | 熔断状态,跳过此供应商 |
|
||||||
|
| 半开 | 尝试恢复,发送试探请求 |
|
||||||
|
|
||||||
|
### 状态转换
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Closed: 初始化
|
||||||
|
Closed --> Open: 失败次数 >= 阈值
|
||||||
|
Open --> HalfOpen: 熔断时长到期
|
||||||
|
HalfOpen --> Closed: 试探成功 (>= 恢复阈值)
|
||||||
|
HalfOpen --> Open: 试探失败
|
||||||
|
```
|
||||||
|
|
||||||
|
## 健康状态指示
|
||||||
|
|
||||||
|
### 供应商卡片
|
||||||
|
|
||||||
|
卡片上显示健康状态徽章:
|
||||||
|
|
||||||
|
| 徽章 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 🟢 | 健康 | 连续失败次数为 0 |
|
||||||
|
| 🟡 | 警告 | 有失败但未触发熔断 |
|
||||||
|
| 🔴 | 熔断 | 已触发熔断,暂时跳过 |
|
||||||
|
|
||||||
|
### 队列列表
|
||||||
|
|
||||||
|
故障转移队列中也显示每个供应商的健康状态。
|
||||||
|
|
||||||
|
## 故障转移日志
|
||||||
|
|
||||||
|
每次故障转移会记录:
|
||||||
|
|
||||||
|
| 信息 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 时间 | 发生时间 |
|
||||||
|
| 原供应商 | 失败的供应商 |
|
||||||
|
| 新供应商 | 切换到的供应商 |
|
||||||
|
| 失败原因 | 错误信息 |
|
||||||
|
|
||||||
|
在用量统计的请求日志中可以查看。
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 队列配置建议
|
||||||
|
|
||||||
|
1. **主供应商**:最稳定、最快的供应商
|
||||||
|
2. **第一备用**:次优选择
|
||||||
|
3. **第二备用**:保底选择
|
||||||
|
|
||||||
|
### 熔断器配置建议
|
||||||
|
|
||||||
|
| 场景 | 失败阈值 | 熔断时长 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 高可用要求 | 2 | 30 秒 |
|
||||||
|
| 一般场景 | 3 | 60 秒 |
|
||||||
|
| 容忍偶发失败 | 5 | 120 秒 |
|
||||||
|
|
||||||
|
### 监控建议
|
||||||
|
|
||||||
|
定期检查:
|
||||||
|
- 各供应商的健康状态
|
||||||
|
- 故障转移发生频率
|
||||||
|
- 熔断触发情况
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 故障转移没有触发
|
||||||
|
|
||||||
|
检查:
|
||||||
|
1. 代理服务是否运行
|
||||||
|
2. 应用接管是否开启
|
||||||
|
3. 自动故障转移是否开启
|
||||||
|
4. 队列中是否有备用供应商
|
||||||
|
|
||||||
|
### 频繁触发故障转移
|
||||||
|
|
||||||
|
可能原因:
|
||||||
|
- 主供应商不稳定
|
||||||
|
- 网络问题
|
||||||
|
- 配置错误
|
||||||
|
|
||||||
|
解决方法:
|
||||||
|
- 检查主供应商状态
|
||||||
|
- 调整熔断器参数
|
||||||
|
- 考虑更换主供应商
|
||||||
|
|
||||||
|
### 所有供应商都熔断
|
||||||
|
|
||||||
|
等待熔断时长到期后自动恢复,或:
|
||||||
|
1. 手动重启代理服务
|
||||||
|
2. 重置熔断状态
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# 4.4 用量统计
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
用量统计功能记录和分析 API 请求数据,帮助你:
|
||||||
|
|
||||||
|
- 了解 API 使用情况
|
||||||
|
- 估算费用支出
|
||||||
|
- 分析使用模式
|
||||||
|
- 排查问题
|
||||||
|
|
||||||
|
## 前提条件
|
||||||
|
|
||||||
|
使用用量统计功能需要:
|
||||||
|
|
||||||
|
1. ✅ 启动代理服务
|
||||||
|
2. ✅ 开启应用接管
|
||||||
|
3. ✅ 开启日志记录
|
||||||
|
|
||||||
|
## 打开用量统计
|
||||||
|
|
||||||
|
设置 → 用量 Tab
|
||||||
|
|
||||||
|
## 统计概览
|
||||||
|
|
||||||
|
### 汇总卡片
|
||||||
|
|
||||||
|
页面顶部显示关键指标:
|
||||||
|
|
||||||
|
| 指标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 总请求数 | 统计周期内的请求总数 |
|
||||||
|
| 总 Token | 输入 + 输出 Token 总数 |
|
||||||
|
| 估算费用 | 基于定价配置计算的费用 |
|
||||||
|
| 成功率 | 成功请求的百分比 |
|
||||||
|
|
||||||
|
### 时间范围
|
||||||
|
|
||||||
|
可选择统计的时间范围:
|
||||||
|
|
||||||
|
| 选项 | 范围 |
|
||||||
|
|------|------|
|
||||||
|
| 今日 | 当天 00:00 至今 |
|
||||||
|
| 最近 7 天 | 过去 7 天 |
|
||||||
|
| 最近 30 天 | 过去 30 天 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 趋势图表
|
||||||
|
|
||||||
|
### 请求趋势
|
||||||
|
|
||||||
|
折线图展示请求数量的变化趋势:
|
||||||
|
|
||||||
|
- X 轴:时间
|
||||||
|
- Y 轴:请求数量
|
||||||
|
- 可按小时/天查看
|
||||||
|
- 支持缩放和拖拽
|
||||||
|
|
||||||
|
### Token 趋势
|
||||||
|
|
||||||
|
展示 Token 使用量的变化:
|
||||||
|
|
||||||
|
- 输入 Token(蓝色)- 用户发送的 prompt 内容
|
||||||
|
- 输出 Token(绿色)- AI 生成的回复内容
|
||||||
|
- 缓存创建 Token(橙色)- 首次创建缓存消耗的 Token
|
||||||
|
- 缓存命中 Token(紫色)- 复用缓存节省的 Token
|
||||||
|
- 成本(红色虚线,右侧 Y 轴)- 估算费用
|
||||||
|
|
||||||
|
> 💡 **缓存 Token 说明**:Anthropic API 支持 Prompt Caching 功能。缓存创建时收取较高费用(通常为输入价格的 1.25 倍),但后续命中缓存时只收取 0.1 倍的价格,可大幅降低重复请求的成本。
|
||||||
|
|
||||||
|
### 时间粒度
|
||||||
|
|
||||||
|
- **今日**:按小时显示(24 个数据点)
|
||||||
|
- **7 天/30 天**:按天显示
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 详细数据
|
||||||
|
|
||||||
|
页面下方有三个数据 Tab:
|
||||||
|
|
||||||
|
### 请求日志
|
||||||
|
|
||||||
|
每条请求的详细记录:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 时间 | 请求时间 |
|
||||||
|
| 供应商 | 使用的供应商名称 |
|
||||||
|
| 模型 | 请求的模型(计费模型) |
|
||||||
|
| 输入 Token | 输入的 Token 数 |
|
||||||
|
| 输出 Token | 输出的 Token 数 |
|
||||||
|
| 缓存读取 | 缓存命中的 Token 数 |
|
||||||
|
| 缓存创建 | 缓存创建的 Token 数 |
|
||||||
|
| 总费用 | 估算费用(美元) |
|
||||||
|
| 耗时信息 | 请求耗时、首 Token 时间、流式/非流式 |
|
||||||
|
| 状态 | HTTP 状态码 |
|
||||||
|
|
||||||
|
#### 耗时信息说明
|
||||||
|
|
||||||
|
耗时信息列显示多个徽章:
|
||||||
|
|
||||||
|
| 徽章 | 说明 | 颜色规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 总耗时 | 请求总时长(秒) | ≤5s 绿色,≤120s 橙色,>120s 红色 |
|
||||||
|
| 首 Token | 流式请求首个 Token 时间 | ≤5s 绿色,≤120s 橙色,>120s 红色 |
|
||||||
|
| 流式/非流式 | 请求类型 | 流式蓝色,非流式紫色 |
|
||||||
|
|
||||||
|
#### 查看详情
|
||||||
|
|
||||||
|
点击请求行可查看详细信息:
|
||||||
|
|
||||||
|
- 完整的请求参数
|
||||||
|
- 响应内容摘要
|
||||||
|
- 错误信息(如果失败)
|
||||||
|
|
||||||
|
#### 筛选日志
|
||||||
|
|
||||||
|
支持按以下条件筛选:
|
||||||
|
|
||||||
|
| 筛选项 | 选项 |
|
||||||
|
|--------|------|
|
||||||
|
| 应用类型 | 全部 / Claude / Codex / Gemini / OpenCode |
|
||||||
|
| 状态码 | 全部 / 200 / 400 / 401 / 429 / 500 |
|
||||||
|
| 供应商 | 文本搜索 |
|
||||||
|
| 模型 | 文本搜索 |
|
||||||
|
| 时间范围 | 开始时间 - 结束时间(日期时间选择器) |
|
||||||
|
|
||||||
|
操作按钮:
|
||||||
|
- **搜索**:应用筛选条件
|
||||||
|
- **重置**:恢复默认(过去 24 小时)
|
||||||
|
- **刷新**:重新加载数据
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 供应商统计
|
||||||
|
|
||||||
|
按供应商分组的统计数据:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 供应商 | 供应商名称 |
|
||||||
|
| 请求数 | 该供应商的请求总数 |
|
||||||
|
| 成功数 | 成功的请求数 |
|
||||||
|
| 失败数 | 失败的请求数 |
|
||||||
|
| 成功率 | 成功百分比 |
|
||||||
|
| 总 Token | Token 使用总量 |
|
||||||
|
| 估算费用 | 该供应商的费用 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 模型统计
|
||||||
|
|
||||||
|
按模型分组的统计数据:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 模型 | 模型名称 |
|
||||||
|
| 请求数 | 该模型的请求总数 |
|
||||||
|
| 输入 Token | 输入 Token 总量 |
|
||||||
|
| 输出 Token | 输出 Token 总量 |
|
||||||
|
| 平均延迟 | 平均响应时间 |
|
||||||
|
| 估算费用 | 该模型的费用 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 定价配置
|
||||||
|
|
||||||
|
### 打开定价配置
|
||||||
|
|
||||||
|
设置 → 高级 → 定价配置
|
||||||
|
|
||||||
|
### 配置模型价格
|
||||||
|
|
||||||
|
为每个模型设置价格(每百万 Token):
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 模型 ID | 模型标识符(如 claude-3-sonnet) |
|
||||||
|
| 显示名称 | 自定义显示名称 |
|
||||||
|
| 输入价格 | 每百万输入 Token 的价格 |
|
||||||
|
| 输出价格 | 每百万输出 Token 的价格 |
|
||||||
|
| 缓存读取价格 | 每百万缓存命中 Token 的价格 |
|
||||||
|
| 缓存创建价格 | 每百万缓存创建 Token 的价格 |
|
||||||
|
|
||||||
|
### 操作
|
||||||
|
|
||||||
|
- **添加**:点击「添加」按钮新增模型定价
|
||||||
|
- **编辑**:点击行末的编辑图标修改
|
||||||
|
- **删除**:点击行末的删除图标移除
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 预设价格
|
||||||
|
|
||||||
|
CC Switch 预设了常用模型的官方价格(每百万 Token):
|
||||||
|
|
||||||
|
**Claude 系列(美元)**:
|
||||||
|
|
||||||
|
| 模型 | 输入 | 输出 | 缓存读取 | 缓存创建 |
|
||||||
|
|------|------|------|----------|----------|
|
||||||
|
| **Claude 4.5 系列** | | | | |
|
||||||
|
| claude-opus-4-5 | $5 | $25 | $0.50 | $6.25 |
|
||||||
|
| claude-sonnet-4-5 | $3 | $15 | $0.30 | $3.75 |
|
||||||
|
| claude-haiku-4-5 | $1 | $5 | $0.10 | $1.25 |
|
||||||
|
| **Claude 4 系列** | | | | |
|
||||||
|
| claude-opus-4 | $15 | $75 | $1.50 | $18.75 |
|
||||||
|
| claude-opus-4-1 | $15 | $75 | $1.50 | $18.75 |
|
||||||
|
| claude-sonnet-4 | $3 | $15 | $0.30 | $3.75 |
|
||||||
|
| **Claude 3.5 系列** | | | | |
|
||||||
|
| claude-3-5-sonnet | $3 | $15 | $0.30 | $3.75 |
|
||||||
|
| claude-3-5-haiku | $0.80 | $4 | $0.08 | $1.00 |
|
||||||
|
|
||||||
|
**OpenAI 系列 / Codex(美元)**:
|
||||||
|
|
||||||
|
| 模型 | 输入 | 输出 | 缓存读取 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| **GPT-5.2 系列** | | | |
|
||||||
|
| gpt-5.2 | $1.75 | $14 | $0.175 |
|
||||||
|
| **GPT-5.1 系列** | | | |
|
||||||
|
| gpt-5.1 | $1.25 | $10 | $0.125 |
|
||||||
|
| **GPT-5 系列** | | | |
|
||||||
|
| gpt-5 | $1.25 | $10 | $0.125 |
|
||||||
|
|
||||||
|
> 注:Codex 预设包含了 low/medium/high 等变体,价格与基础模型一致。
|
||||||
|
|
||||||
|
**Gemini 系列(美元)**:
|
||||||
|
|
||||||
|
| 模型 | 输入 | 输出 | 缓存读取 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| **Gemini 3 系列** | | | |
|
||||||
|
| gemini-3-pro-preview | $2 | $12 | $0.20 |
|
||||||
|
| gemini-3-flash-preview | $0.50 | $3 | $0.05 |
|
||||||
|
| **Gemini 2.5 系列** | | | |
|
||||||
|
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
|
||||||
|
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
|
||||||
|
|
||||||
|
**中国厂商模型(人民币)**:
|
||||||
|
|
||||||
|
| 模型 | 输入 | 输出 | 缓存读取 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| **DeepSeek** | | | |
|
||||||
|
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||||
|
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||||
|
| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |
|
||||||
|
| **Kimi (月之暗面)** | | | |
|
||||||
|
| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |
|
||||||
|
| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |
|
||||||
|
| kimi-k2-turbo | ¥8.00 | ¥58.00 | ¥1.00 |
|
||||||
|
| **MiniMax** | | | |
|
||||||
|
| minimax-m2.1 | ¥2.10 | ¥8.40 | ¥0.21 |
|
||||||
|
| minimax-m2.1-lightning | ¥2.10 | ¥16.80 | ¥0.21 |
|
||||||
|
| **其他** | | | |
|
||||||
|
| glm-4.7 | ¥2.00 | ¥8.00 | ¥0.40 |
|
||||||
|
| doubao-seed-code | ¥1.20 | ¥8.00 | ¥0.24 |
|
||||||
|
| mimo-v2-flash | 免费 | 免费 | - |
|
||||||
|
|
||||||
|
### 自定义价格
|
||||||
|
|
||||||
|
如果使用中转服务,价格可能不同:
|
||||||
|
|
||||||
|
1. 点击「编辑」按钮
|
||||||
|
2. 修改价格
|
||||||
|
3. 保存
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 统计数据为空
|
||||||
|
|
||||||
|
检查:
|
||||||
|
- 代理服务是否运行
|
||||||
|
- 应用接管是否开启
|
||||||
|
- 日志记录是否开启
|
||||||
|
- 是否有请求通过代理
|
||||||
|
|
||||||
|
### 费用估算不准确
|
||||||
|
|
||||||
|
可能原因:
|
||||||
|
- 定价配置与实际不符
|
||||||
|
- 使用了中转服务的特殊定价
|
||||||
|
|
||||||
|
解决方法:
|
||||||
|
- 更新定价配置
|
||||||
|
- 参考供应商的实际账单
|
||||||
|
|
||||||
|
### Token 数量与供应商不一致
|
||||||
|
|
||||||
|
CC Switch 使用自己的方式估算 Token 数,可能与供应商的计算方式略有差异。以供应商账单为准。
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 4.5 模型检查
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
模型检查功能用于验证供应商配置的模型是否可用,通过发送实际的 API 请求来测试:
|
||||||
|
|
||||||
|
- 模型是否存在
|
||||||
|
- API Key 是否有效
|
||||||
|
- 端点是否正常响应
|
||||||
|
- 响应延迟是否正常
|
||||||
|
|
||||||
|
## 打开配置
|
||||||
|
|
||||||
|
设置 → 高级 → 模型测试
|
||||||
|
|
||||||
|
## 测试模型配置
|
||||||
|
|
||||||
|
为每个应用配置用于测试的模型:
|
||||||
|
|
||||||
|
| 应用 | 配置项 | 默认值 | 说明 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| Claude | Claude 模型 | 系统默认 | 建议使用 Haiku 系列(成本低、速度快) |
|
||||||
|
| Codex | Codex 模型 | 系统默认 | 建议使用 mini 系列 |
|
||||||
|
| Gemini | Gemini 模型 | 系统默认 | 建议使用 Flash 系列 |
|
||||||
|
|
||||||
|
### 模型选择建议
|
||||||
|
|
||||||
|
选择测试模型时考虑:
|
||||||
|
|
||||||
|
1. **成本**:选择价格较低的模型(如 Haiku、Mini、Flash)
|
||||||
|
2. **速度**:选择响应快的模型
|
||||||
|
3. **可用性**:选择供应商支持的模型
|
||||||
|
|
||||||
|
## 检查参数配置
|
||||||
|
|
||||||
|
### 超时时间
|
||||||
|
|
||||||
|
| 参数 | 说明 | 默认值 | 范围 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 超时时间 | 单次请求超时 | 45 秒 | 10-120 秒 |
|
||||||
|
|
||||||
|
设置过短可能导致误判,设置过长会延迟故障检测。
|
||||||
|
|
||||||
|
### 重试次数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 默认值 | 范围 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 最大重试 | 失败后重试次数 | 2 次 | 0-5 次 |
|
||||||
|
|
||||||
|
网络不稳定时建议增加重试次数。
|
||||||
|
|
||||||
|
### 降级阈值
|
||||||
|
|
||||||
|
| 参数 | 说明 | 默认值 | 范围 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 降级阈值 | 响应超过此时间标记为降级 | 6000ms | 1000-30000ms |
|
||||||
|
|
||||||
|
超过阈值的供应商会被标记为「降级」状态,但仍可使用。
|
||||||
|
|
||||||
|
## 执行模型检查
|
||||||
|
|
||||||
|
### 手动测试
|
||||||
|
|
||||||
|
在供应商卡片上点击「测试」按钮:
|
||||||
|
|
||||||
|
1. 发送测试请求到配置的端点
|
||||||
|
2. 使用配置的测试模型
|
||||||
|
3. 等待响应或超时
|
||||||
|
4. 显示测试结果
|
||||||
|
|
||||||
|
### 测试内容
|
||||||
|
|
||||||
|
测试请求会:
|
||||||
|
- 发送简短的 prompt(如 "Hi")
|
||||||
|
- 限制最大输出 token(通常 10-50)
|
||||||
|
- 使用流式响应检测首字节时间
|
||||||
|
|
||||||
|
## 测试结果
|
||||||
|
|
||||||
|
### 健康状态
|
||||||
|
|
||||||
|
| 状态 | 图标 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 健康 | 🟢 | 响应正常,延迟在阈值内 |
|
||||||
|
| 降级 | 🟡 | 响应正常,但延迟超过阈值 |
|
||||||
|
| 不可用 | 🔴 | 请求失败或超时 |
|
||||||
|
|
||||||
|
### 结果信息
|
||||||
|
|
||||||
|
测试完成后显示:
|
||||||
|
- 响应延迟(毫秒)
|
||||||
|
- 首字节时间(TTFB)
|
||||||
|
- 错误信息(如果失败)
|
||||||
|
|
||||||
|
## 与故障转移集成
|
||||||
|
|
||||||
|
模型检查与故障转移功能配合使用:
|
||||||
|
|
||||||
|
### 健康检查
|
||||||
|
|
||||||
|
开启代理服务后,系统会定期对故障转移队列中的供应商执行健康检查:
|
||||||
|
|
||||||
|
1. 使用配置的测试模型发送请求
|
||||||
|
2. 根据响应更新健康状态
|
||||||
|
3. 不健康的供应商会被暂时跳过
|
||||||
|
|
||||||
|
### 熔断恢复
|
||||||
|
|
||||||
|
当供应商从熔断状态恢复时:
|
||||||
|
|
||||||
|
1. 执行模型检查验证可用性
|
||||||
|
2. 检查通过后恢复正常状态
|
||||||
|
3. 检查失败则继续熔断
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 测试失败但实际可用
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 测试模型与实际使用的模型不同
|
||||||
|
- 供应商不支持配置的测试模型
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 修改测试模型为供应商支持的模型
|
||||||
|
- 检查供应商的模型列表
|
||||||
|
|
||||||
|
### 延迟过高
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 网络延迟
|
||||||
|
- 供应商服务器负载高
|
||||||
|
- 模型响应慢
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 使用更快的测试模型
|
||||||
|
- 调整降级阈值
|
||||||
|
- 考虑使用镜像端点
|
||||||
|
|
||||||
|
### 频繁超时
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 超时时间设置过短
|
||||||
|
- 网络不稳定
|
||||||
|
- 供应商服务不稳定
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 增加超时时间
|
||||||
|
- 增加重试次数
|
||||||
|
- 检查网络连接
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 模型检查会消耗少量 API 配额
|
||||||
|
- 建议使用低成本模型进行测试
|
||||||
|
- 测试频率不宜过高,避免浪费配额
|
||||||
|
- 不同供应商支持的模型可能不同
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# 5.1 配置文件说明
|
||||||
|
|
||||||
|
## CC Switch 数据存储
|
||||||
|
|
||||||
|
### 存储目录
|
||||||
|
|
||||||
|
默认位置:`~/.cc-switch/`
|
||||||
|
|
||||||
|
可在设置中自定义位置(用于云同步)。
|
||||||
|
|
||||||
|
### 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.cc-switch/
|
||||||
|
├── cc-switch.db # SQLite 数据库
|
||||||
|
├── settings.json # 设备级设置
|
||||||
|
└── backups/ # 自动备份
|
||||||
|
├── backup-20251230-120000.json
|
||||||
|
├── backup-20251229-180000.json
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库内容
|
||||||
|
|
||||||
|
`cc-switch.db` 是 SQLite 数据库,存储:
|
||||||
|
|
||||||
|
| 表 | 内容 |
|
||||||
|
|-----|------|
|
||||||
|
| providers | 供应商配置 |
|
||||||
|
| provider_endpoints | 供应商端点候选列表 |
|
||||||
|
| mcp_servers | MCP 服务器配置 |
|
||||||
|
| prompts | 提示词预设 |
|
||||||
|
| skills | 技能安装状态 |
|
||||||
|
| skill_repos | 技能仓库配置 |
|
||||||
|
| proxy_config | 代理配置 |
|
||||||
|
| proxy_request_logs | 代理请求日志 |
|
||||||
|
| provider_health | 供应商健康状态 |
|
||||||
|
| model_pricing | 模型定价 |
|
||||||
|
| settings | 应用设置 |
|
||||||
|
|
||||||
|
### 设备设置
|
||||||
|
|
||||||
|
`settings.json` 存储设备级设置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"language": "zh",
|
||||||
|
"theme": "system",
|
||||||
|
"windowBehavior": "minimize",
|
||||||
|
"autoStart": false,
|
||||||
|
"claudeConfigDir": null,
|
||||||
|
"codexConfigDir": null,
|
||||||
|
"geminiConfigDir": null,
|
||||||
|
"opencodeConfigDir": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这些设置不会跨设备同步。
|
||||||
|
|
||||||
|
### 自动备份
|
||||||
|
|
||||||
|
`backups/` 目录存储自动备份:
|
||||||
|
|
||||||
|
- 每次导入配置前自动创建
|
||||||
|
- 保留最近 10 个备份
|
||||||
|
- 文件名包含时间戳
|
||||||
|
|
||||||
|
## Claude Code 配置
|
||||||
|
|
||||||
|
### 配置目录
|
||||||
|
|
||||||
|
默认:`~/.claude/`
|
||||||
|
|
||||||
|
### 主要文件
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.claude/
|
||||||
|
├── settings.json # 主配置文件
|
||||||
|
├── CLAUDE.md # 系统提示词
|
||||||
|
└── skills/ # 技能目录
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### settings.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "sk-xxx",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"allow_file_access": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `env.ANTHROPIC_API_KEY` | API 密钥 |
|
||||||
|
| `env.ANTHROPIC_BASE_URL` | API 端点(可选) |
|
||||||
|
| `env.ANTHROPIC_AUTH_TOKEN` | 替代认证方式 |
|
||||||
|
|
||||||
|
### MCP 配置
|
||||||
|
|
||||||
|
MCP 服务器配置在 `~/.claude.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mcp-fetch": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-fetch"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Codex 配置
|
||||||
|
|
||||||
|
### 配置目录
|
||||||
|
|
||||||
|
默认:`~/.codex/`
|
||||||
|
|
||||||
|
### 主要文件
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.codex/
|
||||||
|
├── auth.json # 认证配置
|
||||||
|
├── config.toml # 主配置 + MCP
|
||||||
|
└── AGENTS.md # 系统提示词
|
||||||
|
```
|
||||||
|
|
||||||
|
### auth.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OPENAI_API_KEY": "sk-xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### config.toml
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# 基础配置
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
model = "gpt-4"
|
||||||
|
|
||||||
|
# MCP 服务器
|
||||||
|
[mcp_servers.mcp-fetch]
|
||||||
|
command = "uvx"
|
||||||
|
args = ["mcp-server-fetch"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gemini CLI 配置
|
||||||
|
|
||||||
|
### 配置目录
|
||||||
|
|
||||||
|
默认:`~/.gemini/`
|
||||||
|
|
||||||
|
### 主要文件
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.gemini/
|
||||||
|
├── .env # 环境变量(API Key)
|
||||||
|
├── settings.json # 主配置 + MCP
|
||||||
|
└── GEMINI.md # 系统提示词
|
||||||
|
```
|
||||||
|
|
||||||
|
### .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GEMINI_API_KEY=xxx
|
||||||
|
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||||
|
GEMINI_MODEL=gemini-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
### settings.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mcp-fetch": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-fetch"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `mcpServers` | MCP 服务器配置 |
|
||||||
|
|
||||||
|
## OpenCode 配置
|
||||||
|
|
||||||
|
### 配置目录
|
||||||
|
|
||||||
|
默认:`~/.opencode/`
|
||||||
|
|
||||||
|
### 主要文件
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.opencode/
|
||||||
|
├── config.json # 主配置文件
|
||||||
|
├── AGENTS.md # 系统提示词
|
||||||
|
└── skills/ # 技能目录
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置优先级
|
||||||
|
|
||||||
|
CC Switch 修改配置时的优先级:
|
||||||
|
|
||||||
|
1. **CC Switch 数据库** - 单一事实源 (SSOT)
|
||||||
|
2. **Live 配置文件** - 切换供应商时写入
|
||||||
|
3. **回填机制** - 编辑当前供应商时从 Live 文件读取
|
||||||
|
|
||||||
|
## 手动编辑配置
|
||||||
|
|
||||||
|
### 可以手动编辑
|
||||||
|
|
||||||
|
- CLI 工具的配置文件(会被 CC Switch 回填)
|
||||||
|
- CC Switch 的 `settings.json`
|
||||||
|
|
||||||
|
### 不建议手动编辑
|
||||||
|
|
||||||
|
- `cc-switch.db` 数据库文件
|
||||||
|
- 备份文件
|
||||||
|
|
||||||
|
### 编辑后同步
|
||||||
|
|
||||||
|
如果手动编辑了 CLI 工具的配置:
|
||||||
|
|
||||||
|
1. 打开 CC Switch
|
||||||
|
2. 编辑对应的供应商
|
||||||
|
3. 会看到手动修改的内容已回填
|
||||||
|
4. 保存以同步到数据库
|
||||||
|
|
||||||
|
## 配置迁移
|
||||||
|
|
||||||
|
### 从旧版本迁移
|
||||||
|
|
||||||
|
CC Switch v3.7.0 从 JSON 文件迁移到 SQLite:
|
||||||
|
|
||||||
|
- 首次启动自动迁移
|
||||||
|
- 迁移成功后显示提示
|
||||||
|
- 旧配置文件保留作为备份
|
||||||
|
|
||||||
|
### 跨设备迁移
|
||||||
|
|
||||||
|
1. 在源设备导出配置
|
||||||
|
2. 在目标设备导入配置
|
||||||
|
3. 或使用云同步功能
|
||||||
|
|
||||||
|
## 配置备份建议
|
||||||
|
|
||||||
|
### 定期备份
|
||||||
|
|
||||||
|
建议定期导出配置:
|
||||||
|
|
||||||
|
1. 设置 → 高级 → 数据管理
|
||||||
|
2. 点击「导出」
|
||||||
|
3. 保存到安全位置
|
||||||
|
|
||||||
|
### 备份内容
|
||||||
|
|
||||||
|
导出文件包含:
|
||||||
|
|
||||||
|
- 所有供应商配置
|
||||||
|
- MCP 服务器配置
|
||||||
|
- Prompts 预设
|
||||||
|
- 应用设置
|
||||||
|
|
||||||
|
### 不包含的内容
|
||||||
|
|
||||||
|
- 用量日志(数据量大)
|
||||||
|
- 设备级设置(不适合跨设备)
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# 5.2 常见问题 FAQ
|
||||||
|
|
||||||
|
## 安装问题
|
||||||
|
|
||||||
|
### macOS 提示「未知开发者」
|
||||||
|
|
||||||
|
**问题**:首次打开时提示「无法打开,因为它来自身份不明的开发者」
|
||||||
|
|
||||||
|
**解决方法一**:通过系统设置
|
||||||
|
1. 关闭警告弹窗
|
||||||
|
2. 打开「系统设置」→「隐私与安全性」
|
||||||
|
3. 找到 CC Switch 相关提示
|
||||||
|
4. 点击「仍要打开」
|
||||||
|
5. 再次打开应用
|
||||||
|
|
||||||
|
**解决方法二**:通过终端命令(推荐)
|
||||||
|
```bash
|
||||||
|
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||||
|
```
|
||||||
|
|
||||||
|
执行后即可正常打开应用。
|
||||||
|
|
||||||
|
### Windows 安装后无法启动
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 缺少 WebView2 运行时
|
||||||
|
- 杀毒软件拦截
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 安装 [Microsoft Edge WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)
|
||||||
|
2. 将 CC Switch 添加到杀毒软件白名单
|
||||||
|
|
||||||
|
### Linux 启动报错
|
||||||
|
|
||||||
|
**问题**:AppImage 无法启动
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
```bash
|
||||||
|
# 添加执行权限
|
||||||
|
chmod +x CC-Switch-*.AppImage
|
||||||
|
|
||||||
|
# 如果仍然失败,尝试
|
||||||
|
./CC-Switch-*.AppImage --no-sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## 供应商问题
|
||||||
|
|
||||||
|
### 切换供应商后不生效
|
||||||
|
|
||||||
|
**原因**:CLI 工具需要重新加载配置
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- Claude Code:关闭并重新打开终端,或重启 IDE
|
||||||
|
- Codex:关闭并重新打开终端
|
||||||
|
- Gemini:托盘切换可即时生效,无需重启
|
||||||
|
|
||||||
|
### API Key 无效
|
||||||
|
|
||||||
|
**检查步骤**:
|
||||||
|
1. 确认 API Key 正确复制(无多余空格)
|
||||||
|
2. 确认 API Key 未过期
|
||||||
|
3. 确认端点地址正确
|
||||||
|
4. 使用速度测试验证连接
|
||||||
|
|
||||||
|
### 如何恢复官方登录
|
||||||
|
|
||||||
|
**操作步骤**:
|
||||||
|
1. 选择「官方登录」预设(Claude/Codex)或「Google 官方」预设(Gemini)
|
||||||
|
2. 点击「启用」
|
||||||
|
3. 重启对应的 CLI 工具
|
||||||
|
4. 按照 CLI 工具的登录流程操作
|
||||||
|
|
||||||
|
## 代理问题
|
||||||
|
|
||||||
|
### 代理服务启动失败
|
||||||
|
|
||||||
|
**可能原因**:端口被占用
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查端口占用:
|
||||||
|
```bash
|
||||||
|
# macOS/Linux
|
||||||
|
lsof -i :49152
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
netstat -ano | findstr :49152
|
||||||
|
```
|
||||||
|
2. 关闭占用端口的程序
|
||||||
|
3. 或尝试修改配置恢复默认端口:
|
||||||
|
- 打开「设置 → 代理服务」
|
||||||
|
- 点击「恢复默认」按钮
|
||||||
|
|
||||||
|
### 代理模式下请求超时
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 网络问题
|
||||||
|
- 供应商服务器问题
|
||||||
|
- 代理配置错误
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查网络连接
|
||||||
|
2. 尝试直接访问供应商 API(关闭代理)
|
||||||
|
3. 检查供应商配置是否正确
|
||||||
|
|
||||||
|
### 关闭代理后配置未恢复
|
||||||
|
|
||||||
|
**可能原因**:代理异常退出
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 编辑当前供应商
|
||||||
|
2. 检查端点地址是否正确
|
||||||
|
3. 保存以更新配置
|
||||||
|
|
||||||
|
## 故障转移问题
|
||||||
|
|
||||||
|
### 故障转移没有触发
|
||||||
|
|
||||||
|
**检查清单**:
|
||||||
|
- [ ] 代理服务是否运行
|
||||||
|
- [ ] 应用接管是否开启
|
||||||
|
- [ ] 自动故障转移是否开启
|
||||||
|
- [ ] 队列中是否有备用供应商
|
||||||
|
|
||||||
|
### 频繁触发故障转移
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 主供应商不稳定
|
||||||
|
- 熔断器阈值设置过低
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查主供应商状态
|
||||||
|
2. 调高失败阈值(如从 3 改为 5)
|
||||||
|
3. 考虑更换主供应商
|
||||||
|
|
||||||
|
### 所有供应商都熔断了
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 等待熔断时长到期(默认 60 秒)
|
||||||
|
2. 或重启代理服务重置状态
|
||||||
|
|
||||||
|
## 数据问题
|
||||||
|
|
||||||
|
### 配置丢失
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 配置目录被删除
|
||||||
|
- 数据库损坏
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查 `~/.cc-switch/` 目录是否存在
|
||||||
|
2. 从备份恢复:`~/.cc-switch/backups/`
|
||||||
|
3. 或从之前导出的配置文件导入
|
||||||
|
|
||||||
|
### 导入配置失败
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 文件格式错误
|
||||||
|
- 版本不兼容
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 确认文件是 CC Switch 导出的 JSON 文件
|
||||||
|
2. 检查文件内容是否完整
|
||||||
|
3. 尝试用文本编辑器打开检查格式
|
||||||
|
|
||||||
|
### 用量统计数据为空
|
||||||
|
|
||||||
|
**检查清单**:
|
||||||
|
- [ ] 代理服务是否运行
|
||||||
|
- [ ] 应用接管是否开启
|
||||||
|
- [ ] 日志记录是否开启
|
||||||
|
- [ ] 是否有请求通过代理
|
||||||
|
|
||||||
|
## 其他问题
|
||||||
|
|
||||||
|
### 托盘图标不显示
|
||||||
|
|
||||||
|
**macOS**:
|
||||||
|
- 检查系统设置中的菜单栏图标设置
|
||||||
|
|
||||||
|
**Windows**:
|
||||||
|
- 检查任务栏设置,确保 CC Switch 图标未被隐藏
|
||||||
|
|
||||||
|
**Linux**:
|
||||||
|
- 需要安装系统托盘支持(如 `libappindicator`)
|
||||||
|
|
||||||
|
### 界面显示异常
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 尝试切换主题(浅色/深色)
|
||||||
|
2. 重启应用
|
||||||
|
3. 删除 `~/.cc-switch/settings.json` 重置设置
|
||||||
|
|
||||||
|
### 更新失败
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查网络连接
|
||||||
|
2. 手动下载最新版本安装
|
||||||
|
3. 如使用 Homebrew:`brew upgrade --cask cc-switch`
|
||||||
|
|
||||||
|
## 获取帮助
|
||||||
|
|
||||||
|
### 提交 Issue
|
||||||
|
|
||||||
|
如果以上方法都无法解决问题:
|
||||||
|
|
||||||
|
1. 访问 [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
2. 搜索是否有类似问题
|
||||||
|
3. 如果没有,创建新 Issue
|
||||||
|
4. 提供以下信息:
|
||||||
|
- 操作系统和版本
|
||||||
|
- CC Switch 版本
|
||||||
|
- 问题描述和复现步骤
|
||||||
|
- 错误信息(如有)
|
||||||
|
|
||||||
|
### 日志文件
|
||||||
|
|
||||||
|
提交 Issue 时可附上日志文件:
|
||||||
|
|
||||||
|
- macOS/Linux:`~/.cc-switch/logs/`
|
||||||
|
- Windows:`%APPDATA%\cc-switch\logs\`
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 5.3 深度链接协议
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
CC Switch 支持 `ccswitch://` 深度链接协议,可以通过链接一键导入配置。
|
||||||
|
|
||||||
|
**使用场景**:
|
||||||
|
- 团队共享配置
|
||||||
|
- 教程中的一键配置
|
||||||
|
- 跨设备快速同步
|
||||||
|
|
||||||
|
## 在线生成工具
|
||||||
|
|
||||||
|
CC Switch 提供在线深度链接生成工具:
|
||||||
|
|
||||||
|
**访问地址**:[https://farion1231.github.io/cc-switch/deplink.html](https://farion1231.github.io/cc-switch/deplink.html)
|
||||||
|
|
||||||
|
### 使用方法
|
||||||
|
|
||||||
|
1. 打开上述网页
|
||||||
|
2. 选择导入类型(供应商/MCP/Prompt)
|
||||||
|
3. 填写配置信息
|
||||||
|
4. 点击「生成链接」
|
||||||
|
5. 复制生成的深度链接
|
||||||
|
6. 分享给他人或在其他设备使用
|
||||||
|
|
||||||
|
## 协议格式
|
||||||
|
|
||||||
|
### V1 协议
|
||||||
|
|
||||||
|
使用 URL 参数格式,易读易生成:
|
||||||
|
|
||||||
|
```
|
||||||
|
ccswitch://v1/import?resource={type}&app={app}&name={name}&...
|
||||||
|
```
|
||||||
|
|
||||||
|
**通用参数**:
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `resource` | 是 | 资源类型:`provider` / `mcp` / `prompt` / `skill` |
|
||||||
|
| `app` | 是 | 应用类型:`claude` / `codex` / `gemini` / `opencode` |
|
||||||
|
| `name` | 是 | 名称 |
|
||||||
|
|
||||||
|
**供应商参数**(resource=provider):
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `endpoint` | 否 | API 端点地址(支持逗号分隔多个 URL) |
|
||||||
|
| `apiKey` | 否 | API 密钥 |
|
||||||
|
| `homepage` | 否 | 供应商官网 |
|
||||||
|
| `model` | 否 | 默认模型 |
|
||||||
|
| `haikuModel` | 否 | Haiku 模型(仅 Claude) |
|
||||||
|
| `sonnetModel` | 否 | Sonnet 模型(仅 Claude) |
|
||||||
|
| `opusModel` | 否 | Opus 模型(仅 Claude) |
|
||||||
|
| `notes` | 否 | 备注 |
|
||||||
|
| `icon` | 否 | 图标 |
|
||||||
|
| `config` | 否 | Base64 编码的配置内容 |
|
||||||
|
| `configFormat` | 否 | 配置格式:`json` / `toml` |
|
||||||
|
| `configUrl` | 否 | 远程配置 URL |
|
||||||
|
| `enabled` | 否 | 是否启用(布尔值) |
|
||||||
|
| `usageScript` | 否 | 用量查询脚本 |
|
||||||
|
| `usageEnabled` | 否 | 是否启用用量查询(默认 true) |
|
||||||
|
| `usageApiKey` | 否 | 用量查询专用 API Key |
|
||||||
|
| `usageBaseUrl` | 否 | 用量查询专用地址 |
|
||||||
|
| `usageAccessToken` | 否 | 用量查询访问令牌 |
|
||||||
|
| `usageUserId` | 否 | 用量查询用户 ID |
|
||||||
|
| `usageAutoInterval` | 否 | 自动查询间隔(分钟) |
|
||||||
|
|
||||||
|
**提示词参数**(resource=prompt):
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `content` | 是 | 提示词内容 |
|
||||||
|
| `description` | 否 | 描述 |
|
||||||
|
| `enabled` | 否 | 是否启用(布尔值) |
|
||||||
|
|
||||||
|
**MCP 参数**(resource=mcp):
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `apps` | 是 | 应用列表(逗号分隔,如 `claude,codex,gemini`) |
|
||||||
|
| `config` | 是 | MCP 服务器配置(JSON 格式) |
|
||||||
|
| `enabled` | 否 | 是否启用(布尔值) |
|
||||||
|
|
||||||
|
**Skill 参数**(resource=skill):
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `repo` | 是 | 仓库(格式:`owner/name`) |
|
||||||
|
| `directory` | 否 | 目录路径 |
|
||||||
|
| `branch` | 否 | Git 分支 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```
|
||||||
|
ccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 导入类型示例
|
||||||
|
|
||||||
|
### 导入供应商
|
||||||
|
|
||||||
|
```
|
||||||
|
ccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 导入 MCP 服务器
|
||||||
|
|
||||||
|
```
|
||||||
|
ccswitch://v1/import?resource=mcp&apps=claude,codex&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D&name=mcp-fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 导入 Prompt 预设
|
||||||
|
|
||||||
|
```
|
||||||
|
ccswitch://v1/import?resource=prompt&app=claude&name=%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5&content=%23%20%E8%A7%92%E8%89%B2%0A%E4%BD%A0%E6%98%AF%E4%B8%80%E4%B8%AA%E4%B8%93%E4%B8%9A%E7%9A%84%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5%E4%B8%93%E5%AE%B6
|
||||||
|
```
|
||||||
|
|
||||||
|
### 导入 Skill
|
||||||
|
|
||||||
|
```
|
||||||
|
ccswitch://v1/import?resource=skill&name=my-skill&repo=owner/repo&directory=skills/my-skill&branch=main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生成深度链接
|
||||||
|
|
||||||
|
### 手动生成
|
||||||
|
|
||||||
|
1. 准备参数
|
||||||
|
2. 按 V1 协议格式拼接 URL
|
||||||
|
3. URL 编码特殊字符
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
resource: 'provider',
|
||||||
|
app: 'claude',
|
||||||
|
name: 'My Provider',
|
||||||
|
endpoint: 'https://api.example.com',
|
||||||
|
apiKey: 'sk-xxx'
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `ccswitch://v1/import?${params.toString()}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 在线工具
|
||||||
|
|
||||||
|
使用 CC Switch 官方提供的在线深度链接生成工具更方便。
|
||||||
|
|
||||||
|
## 使用深度链接
|
||||||
|
|
||||||
|
### 点击链接
|
||||||
|
|
||||||
|
在浏览器或其他应用中点击深度链接:
|
||||||
|
|
||||||
|
1. 系统会询问是否打开 CC Switch
|
||||||
|
2. 确认后 CC Switch 打开
|
||||||
|
3. 显示导入确认对话框
|
||||||
|
4. 确认导入
|
||||||
|
|
||||||
|
### 导入确认
|
||||||
|
|
||||||
|
导入前会显示确认对话框,包含:
|
||||||
|
|
||||||
|
- 导入类型
|
||||||
|
- 配置预览
|
||||||
|
- 确认/取消按钮
|
||||||
|
|
||||||
|
**安全提示**:只导入来自可信来源的配置。
|
||||||
|
|
||||||
|
## 协议注册
|
||||||
|
|
||||||
|
### 自动注册
|
||||||
|
|
||||||
|
CC Switch 安装时会自动注册 `ccswitch://` 协议。
|
||||||
|
|
||||||
|
### 手动注册
|
||||||
|
|
||||||
|
如果协议未正确注册:
|
||||||
|
|
||||||
|
**macOS**:
|
||||||
|
重新安装应用,或运行:
|
||||||
|
```bash
|
||||||
|
/usr/bin/open -a "CC Switch" --args --register-protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows**:
|
||||||
|
重新安装应用,或检查注册表:
|
||||||
|
```
|
||||||
|
HKEY_CLASSES_ROOT\ccswitch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux**:
|
||||||
|
检查 `.desktop` 文件中的 `MimeType` 配置。
|
||||||
|
|
||||||
|
## 安全考虑
|
||||||
|
|
||||||
|
### 敏感信息
|
||||||
|
|
||||||
|
深度链接中可能包含敏感信息(如 API Key):
|
||||||
|
|
||||||
|
- 不要在公开场合分享包含 API Key 的链接
|
||||||
|
- 分享前移除或替换敏感信息
|
||||||
|
- 使用安全渠道传输链接
|
||||||
|
|
||||||
|
### 验证来源
|
||||||
|
|
||||||
|
导入前 CC Switch 会:
|
||||||
|
|
||||||
|
1. 验证数据格式
|
||||||
|
2. 显示配置预览
|
||||||
|
3. 要求用户确认
|
||||||
|
|
||||||
|
### 恶意链接防护
|
||||||
|
|
||||||
|
CC Switch 会检查:
|
||||||
|
|
||||||
|
- 数据格式是否合法
|
||||||
|
- 必填字段是否完整
|
||||||
|
- 配置值是否在合理范围
|
||||||
|
|
||||||
|
## 示例链接
|
||||||
|
|
||||||
|
### 示例:导入 Claude 供应商
|
||||||
|
|
||||||
|
```
|
||||||
|
ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&apiKey=sk-xxx&endpoint=https%3A%2F%2Fapi.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例:导入 MCP 服务器
|
||||||
|
|
||||||
|
```
|
||||||
|
ccswitch://v1/import?resource=mcp&name=mcp-fetch&apps=claude,codex,gemini&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 链接无法打开
|
||||||
|
|
||||||
|
**检查**:
|
||||||
|
1. CC Switch 是否已安装
|
||||||
|
2. 协议是否正确注册
|
||||||
|
3. 链接格式是否正确
|
||||||
|
|
||||||
|
### 导入失败
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- Base64 编码错误
|
||||||
|
- JSON 格式错误
|
||||||
|
- 缺少必填字段
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查原始 JSON 格式
|
||||||
|
2. 重新进行 Base64 编码
|
||||||
|
3. 确保所有必填字段都存在
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 5.4 环境变量冲突
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
CC Switch 会自动检测系统环境变量与应用配置的冲突,避免配置被意外覆盖。
|
||||||
|
|
||||||
|
**检测的环境变量**:
|
||||||
|
- `ANTHROPIC_API_KEY` - Claude API 密钥
|
||||||
|
- `ANTHROPIC_BASE_URL` - Claude API 端点
|
||||||
|
- `OPENAI_API_KEY` - OpenAI API 密钥
|
||||||
|
- `GEMINI_API_KEY` - Gemini API 密钥
|
||||||
|
- 其他相关环境变量
|
||||||
|
|
||||||
|
## 冲突警告
|
||||||
|
|
||||||
|
当检测到冲突时,界面顶部会显示黄色警告横幅:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ 检测到环境变量冲突
|
||||||
|
发现 X 个环境变量可能与 CC Switch 配置冲突
|
||||||
|
[展开] [关闭]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 查看冲突详情
|
||||||
|
|
||||||
|
点击「展开」按钮查看详细信息:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 变量名 | 环境变量名称 |
|
||||||
|
| 变量值 | 当前设置的值 |
|
||||||
|
| 来源 | 变量的来源位置 |
|
||||||
|
|
||||||
|
### 来源类型
|
||||||
|
|
||||||
|
| 来源 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 用户注册表 | Windows 用户级环境变量 |
|
||||||
|
| 系统注册表 | Windows 系统级环境变量 |
|
||||||
|
| Shell 配置 | macOS/Linux 的 shell 配置文件 |
|
||||||
|
| 系统环境 | 系统级环境变量 |
|
||||||
|
|
||||||
|
## 处理冲突
|
||||||
|
|
||||||
|
### 选择要删除的变量
|
||||||
|
|
||||||
|
1. 勾选要删除的环境变量
|
||||||
|
2. 或点击「全选」选择所有冲突变量
|
||||||
|
|
||||||
|
### 删除变量
|
||||||
|
|
||||||
|
1. 点击「删除选中」按钮
|
||||||
|
2. 确认删除操作
|
||||||
|
3. CC Switch 会自动备份并删除选中的变量
|
||||||
|
|
||||||
|
### 自动备份
|
||||||
|
|
||||||
|
删除前会自动备份:
|
||||||
|
|
||||||
|
- 备份位置:`~/.cc-switch/env-backups/`
|
||||||
|
- 备份格式:JSON 文件
|
||||||
|
- 包含变量名、值、来源等信息
|
||||||
|
|
||||||
|
## 忽略警告
|
||||||
|
|
||||||
|
如果确认冲突不影响使用,可以:
|
||||||
|
|
||||||
|
1. 点击警告横幅右侧的「关闭」按钮
|
||||||
|
2. 警告会暂时隐藏
|
||||||
|
3. 下次启动时会重新检测
|
||||||
|
|
||||||
|
## 手动处理
|
||||||
|
|
||||||
|
如果不想通过 CC Switch 删除,可以手动处理:
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
1. 打开「系统属性 → 高级 → 环境变量」
|
||||||
|
2. 在用户变量或系统变量中找到冲突变量
|
||||||
|
3. 删除或修改变量
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
1. 编辑 shell 配置文件(如 `~/.zshrc`、`~/.bashrc`)
|
||||||
|
2. 删除或注释掉相关的 `export` 语句
|
||||||
|
3. 重新加载配置:`source ~/.zshrc`
|
||||||
|
|
||||||
|
## 为什么会冲突
|
||||||
|
|
||||||
|
环境变量的优先级通常高于配置文件,可能导致:
|
||||||
|
|
||||||
|
- CC Switch 设置的供应商配置被覆盖
|
||||||
|
- API 请求发送到错误的端点
|
||||||
|
- 使用错误的 API 密钥
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **使用 CC Switch 管理配置**:避免在系统环境变量中设置 API 密钥
|
||||||
|
2. **定期检查**:关注冲突警告,及时处理
|
||||||
|
3. **备份重要变量**:删除前确认已备份
|
||||||
|
|
||||||
|
## 恢复已删除的变量
|
||||||
|
|
||||||
|
如果误删了环境变量:
|
||||||
|
|
||||||
|
1. 找到备份文件:`~/.cc-switch/env-backups/`
|
||||||
|
2. 打开对应的 JSON 文件
|
||||||
|
3. 手动恢复变量到系统环境
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# CC Switch 用户手册
|
||||||
|
|
||||||
|
> Claude Code / Codex / Gemini CLI / OpenCode 全方位辅助工具
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
📚 CC Switch 用户手册
|
||||||
|
│
|
||||||
|
├── 1. 快速入门
|
||||||
|
│ ├── 1.1 软件介绍
|
||||||
|
│ ├── 1.2 安装指南
|
||||||
|
│ ├── 1.3 界面概览
|
||||||
|
│ ├── 1.4 快速上手
|
||||||
|
│ └── 1.5 个性化配置
|
||||||
|
│
|
||||||
|
├── 2. 供应商管理
|
||||||
|
│ ├── 2.1 添加供应商
|
||||||
|
│ ├── 2.2 切换供应商
|
||||||
|
│ ├── 2.3 编辑供应商
|
||||||
|
│ ├── 2.4 排序与复制
|
||||||
|
│ └── 2.5 用量查询
|
||||||
|
│
|
||||||
|
├── 3. 扩展功能
|
||||||
|
│ ├── 3.1 MCP 服务器管理
|
||||||
|
│ ├── 3.2 Prompts 提示词管理
|
||||||
|
│ └── 3.3 Skills 技能管理
|
||||||
|
│
|
||||||
|
├── 4. 代理与高可用
|
||||||
|
│ ├── 4.1 代理服务
|
||||||
|
│ ├── 4.2 应用接管
|
||||||
|
│ ├── 4.3 故障转移
|
||||||
|
│ ├── 4.4 用量统计
|
||||||
|
│ └── 4.5 模型检查
|
||||||
|
│
|
||||||
|
└── 5. 常见问题
|
||||||
|
├── 5.1 配置文件说明
|
||||||
|
├── 5.2 FAQ
|
||||||
|
├── 5.3 深度链接协议
|
||||||
|
└── 5.4 环境变量冲突
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件列表
|
||||||
|
|
||||||
|
### 1. 快速入门
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [1.1-introduction.md](./1-getting-started/1.1-introduction.md) | 软件介绍、核心功能、支持平台 |
|
||||||
|
| [1.2-installation.md](./1-getting-started/1.2-installation.md) | Windows/macOS/Linux 安装指南 |
|
||||||
|
| [1.3-interface.md](./1-getting-started/1.3-interface.md) | 界面布局、导航栏、供应商卡片说明 |
|
||||||
|
| [1.4-quickstart.md](./1-getting-started/1.4-quickstart.md) | 5 分钟快速上手教程 |
|
||||||
|
| [1.5-settings.md](./1-getting-started/1.5-settings.md) | 语言、主题、目录、云同步配置 |
|
||||||
|
|
||||||
|
### 2. 供应商管理
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [2.1-add.md](./2-providers/2.1-add.md) | 使用预设、自定义配置、统一供应商 |
|
||||||
|
| [2.2-switch.md](./2-providers/2.2-switch.md) | 主界面切换、托盘切换、生效方式 |
|
||||||
|
| [2.3-edit.md](./2-providers/2.3-edit.md) | 编辑配置、修改 API Key、回填机制 |
|
||||||
|
| [2.4-sort-duplicate.md](./2-providers/2.4-sort-duplicate.md) | 拖拽排序、复制供应商、删除 |
|
||||||
|
| [2.5-usage-query.md](./2-providers/2.5-usage-query.md) | 用量查询、剩余额度、多套餐显示 |
|
||||||
|
|
||||||
|
### 3. 扩展功能
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP 协议、添加服务器、应用绑定 |
|
||||||
|
| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | 创建预设、激活切换、智能回填 |
|
||||||
|
| [3.3-skills.md](./3-extensions/3.3-skills.md) | 发现技能、安装卸载、仓库管理 |
|
||||||
|
|
||||||
|
### 4. 代理与高可用
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [4.1-service.md](./4-proxy/4.1-service.md) | 启动代理、配置项、运行状态 |
|
||||||
|
| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | 应用接管、配置修改、状态指示 |
|
||||||
|
| [4.3-failover.md](./4-proxy/4.3-failover.md) | 故障转移队列、熔断器、健康状态 |
|
||||||
|
| [4.4-usage.md](./4-proxy/4.4-usage.md) | 用量统计、趋势图表、定价配置 |
|
||||||
|
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | 模型检查、健康检测、延迟测试 |
|
||||||
|
|
||||||
|
### 5. 常见问题
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [5.1-config-files.md](./5-faq/5.1-config-files.md) | CC Switch 存储、CLI 配置文件格式 |
|
||||||
|
| [5.2-questions.md](./5-faq/5.2-questions.md) | 常见问题解答 |
|
||||||
|
| [5.3-deeplink.md](./5-faq/5.3-deeplink.md) | 深度链接协议、生成和使用方法 |
|
||||||
|
| [5.4-env-conflict.md](./5-faq/5.4-env-conflict.md) | 环境变量冲突检测与处理 |
|
||||||
|
|
||||||
|
## 快速链接
|
||||||
|
|
||||||
|
- **新用户**:从 [1.1 软件介绍](./1-getting-started/1.1-introduction.md) 开始
|
||||||
|
- **安装问题**:查看 [1.2 安装指南](./1-getting-started/1.2-installation.md)
|
||||||
|
- **配置供应商**:查看 [2.1 添加供应商](./2-providers/2.1-add.md)
|
||||||
|
- **使用代理**:查看 [4.1 代理服务](./4-proxy/4.1-service.md)
|
||||||
|
- **遇到问题**:查看 [5.2 FAQ](./5-faq/5.2-questions.md)
|
||||||
|
|
||||||
|
## 版本信息
|
||||||
|
|
||||||
|
- 文档版本:v3.10.3
|
||||||
|
- 最后更新:2026-02-09
|
||||||
|
- 适用于 CC Switch v3.10.0+
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 或 PR 改进文档:
|
||||||
|
|
||||||
|
- [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- [GitHub Repository](https://github.com/farion1231/cc-switch)
|
||||||
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 611 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 218 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.10.2",
|
"version": "3.10.3",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -57,10 +57,13 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.90.3",
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
@@ -72,6 +75,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"flexsearch": "^0.8.212",
|
||||||
"framer-motion": "^12.23.25",
|
"framer-motion": "^12.23.25",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"jsonc-parser": "^3.2.1",
|
"jsonc-parser": "^3.2.1",
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ importers:
|
|||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.7
|
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)
|
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-popover':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@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':
|
'@radix-ui/react-select':
|
||||||
specifier: ^2.2.6
|
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)
|
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)
|
||||||
@@ -71,6 +77,9 @@ importers:
|
|||||||
'@radix-ui/react-tabs':
|
'@radix-ui/react-tabs':
|
||||||
specifier: ^1.1.13
|
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)
|
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':
|
'@radix-ui/react-visually-hidden':
|
||||||
specifier: ^1.2.4
|
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)
|
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)
|
||||||
@@ -104,6 +113,9 @@ importers:
|
|||||||
codemirror:
|
codemirror:
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
|
flexsearch:
|
||||||
|
specifier: ^0.8.212
|
||||||
|
version: 0.8.212
|
||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^12.23.25
|
specifier: ^12.23.25
|
||||||
version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -1039,6 +1051,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15':
|
||||||
|
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||||
|
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-popper@1.2.8':
|
'@radix-ui/react-popper@1.2.8':
|
||||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1117,6 +1142,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-select@2.2.6':
|
||||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1174,6 +1212,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1323,56 +1374,67 @@ packages:
|
|||||||
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
|
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
|
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
|
||||||
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
|
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.46.2':
|
'@rollup/rollup-linux-arm64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
|
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.46.2':
|
'@rollup/rollup-linux-arm64-musl@4.46.2':
|
||||||
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
|
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
|
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
|
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
|
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
|
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
|
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
|
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.46.2':
|
'@rollup/rollup-linux-riscv64-musl@4.46.2':
|
||||||
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
|
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.46.2':
|
'@rollup/rollup-linux-s390x-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
|
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.46.2':
|
'@rollup/rollup-linux-x64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
|
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.46.2':
|
'@rollup/rollup-linux-x64-musl@4.46.2':
|
||||||
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
|
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.46.2':
|
'@rollup/rollup-win32-arm64-msvc@4.46.2':
|
||||||
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
|
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
|
||||||
@@ -1429,30 +1491,35 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-arm64-musl@2.8.1':
|
'@tauri-apps/cli-linux-arm64-musl@2.8.1':
|
||||||
resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==}
|
resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-riscv64-gnu@2.8.1':
|
'@tauri-apps/cli-linux-riscv64-gnu@2.8.1':
|
||||||
resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==}
|
resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-x64-gnu@2.8.1':
|
'@tauri-apps/cli-linux-x64-gnu@2.8.1':
|
||||||
resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==}
|
resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-x64-musl@2.8.1':
|
'@tauri-apps/cli-linux-x64-musl@2.8.1':
|
||||||
resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==}
|
resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tauri-apps/cli-win32-arm64-msvc@2.8.1':
|
'@tauri-apps/cli-win32-arm64-msvc@2.8.1':
|
||||||
resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==}
|
resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==}
|
||||||
@@ -1995,6 +2062,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
flexsearch@0.8.212:
|
||||||
|
resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==}
|
||||||
|
|
||||||
form-data@4.0.4:
|
form-data@4.0.4:
|
||||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2211,24 +2281,28 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.30.1:
|
lightningcss-linux-arm64-musl@1.30.1:
|
||||||
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
|
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.30.1:
|
lightningcss-linux-x64-gnu@1.30.1:
|
||||||
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
|
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.30.1:
|
lightningcss-linux-x64-musl@1.30.1:
|
||||||
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
|
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.30.1:
|
lightningcss-win32-arm64-msvc@1.30.1:
|
||||||
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
|
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
|
||||||
@@ -3832,6 +3906,29 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15(@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-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-focus-scope': 1.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-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)
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
'@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-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)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -3905,6 +4002,23 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/number': 1.1.1
|
'@radix-ui/number': 1.1.1
|
||||||
@@ -3979,6 +4093,26 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@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)':
|
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@@ -4758,6 +4892,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
flexsearch@0.8.212: {}
|
||||||
|
|
||||||
form-data@4.0.4:
|
form-data@4.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
asynckit: 0.4.0
|
asynckit: 0.4.0
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# 会话管理(Session Manager)需求文档(PRD / Markdown)
|
||||||
|
|
||||||
|
> 目标:对 **Codex / Claude Code** 的本地会话记录进行可视化管理,并提供“一键复制 / 一键终端恢复”能力。
|
||||||
|
> 范围:**v1 仅 macOS**,但必须预留多平台扩展入口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与问题
|
||||||
|
|
||||||
|
开发者同时使用 Codex CLI、Claude Code 时,常见痛点:
|
||||||
|
- 会话记录落在本地不同位置,**难以发现/检索**
|
||||||
|
- 找到会话后,恢复命令需要记忆或翻历史,**恢复成本高**
|
||||||
|
- 恢复时经常忘了当时的工作目录,导致命令在错误目录运行
|
||||||
|
- 希望在常用终端(macOS Terminal、kitty 等)中直接恢复,提高效率
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
### 2.1 Goals(v1 必达)
|
||||||
|
1. 扫描并展示本机所有 Codex / Claude Code 会话:列表 + 详情(会话内容)
|
||||||
|
2. 支持恢复会话:
|
||||||
|
- 复制恢复命令(按钮)
|
||||||
|
- 复制会话目录(按钮,若能获取/推断)
|
||||||
|
- 可选:直接在终端执行恢复(macOS Terminal、kitty;可扩展)
|
||||||
|
3. 仅 macOS 支持,但代码结构需支持未来扩展 Windows/Linux
|
||||||
|
|
||||||
|
### 2.2 Non-Goals(v1 不做)
|
||||||
|
- 不新增/依赖云端 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 列表
|
||||||
|
- 支持 Provider:Codex、Claude Code(可扩展)
|
||||||
|
- 支持全量扫描 + 增量更新
|
||||||
|
- 支持缺失/异常文件的容错(不中断 UI)
|
||||||
|
|
||||||
|
**FR-2** 本地索引(Cache/DB)
|
||||||
|
- 用于加速列表加载与搜索
|
||||||
|
- 索引字段至少包含:sessionId、provider、lastActiveAt、projectDir(可空)、summary(可空)、filePath(可空)
|
||||||
|
|
||||||
|
**FR-3** 数据源路径探测(可配置 + 多候选)
|
||||||
|
- 默认使用常见路径;允许用户在 Settings 覆盖
|
||||||
|
- 若无法探测到 provider 安装/数据目录:在 UI 显示未启用/不可用状态,但不报错崩溃
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 会话列表(List)
|
||||||
|
**FR-4** 列表展示字段(建议最小集)
|
||||||
|
- Provider(Codex / Claude)
|
||||||
|
- Session 标识(id/short id)
|
||||||
|
- 最近活跃时间(lastActiveAt)
|
||||||
|
- 目录(projectDir,若未知显示 “Unknown”)
|
||||||
|
- 摘要(summary:最后一条/首条截断或规则生成)
|
||||||
|
|
||||||
|
**FR-5** 列表交互
|
||||||
|
- 搜索(跨会话,关键词匹配 transcript/summary/目录)
|
||||||
|
- 过滤:Provider、是否有目录、时间范围
|
||||||
|
- 排序:最近活跃(默认)、最早、按目录
|
||||||
|
|
||||||
|
**FR-6** 空态/异常态
|
||||||
|
- 未发现任何会话:给出“如何启用/设置路径”的指引
|
||||||
|
- 发现会话但无法解析内容:列表仍可显示基本信息,并在详情页提示“解析失败”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 会话详情(Detail)
|
||||||
|
**FR-7** 会话内容展示
|
||||||
|
- 时间线展示消息(role:user/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
|
||||||
|
- 支持 kitty(v1 要求)
|
||||||
|
- 执行策略:
|
||||||
|
- `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
|
||||||
|
- Future:TerminalLauncherWindows / 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 视图)
|
||||||
|
- 会话清理/归档(磁盘管理)
|
||||||
|
|
||||||
|
---
|
||||||
@@ -701,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.10.2"
|
version = "3.10.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -746,6 +746,7 @@ dependencies = [
|
|||||||
"tower-http 0.5.2",
|
"tower-http 0.5.2",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"webkit2gtk",
|
||||||
"winreg 0.52.0",
|
"winreg 0.52.0",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
@@ -5633,6 +5634,7 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"indexmap 2.11.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned 0.6.9",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.11",
|
"toml_datetime 0.6.11",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.10.2"
|
version = "3.10.3"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -35,7 +35,7 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = { version = "0.8", features = ["preserve_order"] }
|
||||||
toml_edit = "0.22"
|
toml_edit = "0.22"
|
||||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||||
@@ -65,6 +65,9 @@ uuid = { version = "1.11", features = ["v4"] }
|
|||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
winreg = "0.52"
|
winreg = "0.52"
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,20 @@ pub struct CommonConfigSnippets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonConfigSnippets {
|
impl CommonConfigSnippets {
|
||||||
|
/// 检查是否所有字段都为空
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
let is_blank = |value: &Option<String>| {
|
||||||
|
value
|
||||||
|
.as_ref()
|
||||||
|
.map(|snippet| snippet.trim().is_empty())
|
||||||
|
.unwrap_or(true)
|
||||||
|
};
|
||||||
|
is_blank(&self.claude)
|
||||||
|
&& is_blank(&self.codex)
|
||||||
|
&& is_blank(&self.gemini)
|
||||||
|
&& is_blank(&self.opencode)
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取指定应用的通用配置片段
|
/// 获取指定应用的通用配置片段
|
||||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||||
match app {
|
match app {
|
||||||
@@ -378,7 +392,9 @@ pub struct MultiAppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skills: SkillStore,
|
pub skills: SkillStore,
|
||||||
/// 通用配置片段(按应用分治)
|
/// 通用配置片段(按应用分治)
|
||||||
#[serde(default)]
|
/// 注意:此字段主要用于从旧版 config.json 迁移数据到数据库
|
||||||
|
/// 迁移成功后会被清空,空时不写入 config.json
|
||||||
|
#[serde(default, skip_serializing_if = "CommonConfigSnippets::is_empty")]
|
||||||
pub common_config_snippets: CommonConfigSnippets,
|
pub common_config_snippets: CommonConfigSnippets,
|
||||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use crate::codex_config;
|
|||||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||||
use crate::settings;
|
use crate::settings;
|
||||||
|
|
||||||
/// 获取 Claude Code 配置状态
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||||
Ok(config::get_claude_config_status())
|
Ok(config::get_claude_config_status())
|
||||||
@@ -63,13 +62,11 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Claude Code 配置文件路径
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||||
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前生效的配置目录
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||||
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
@@ -82,7 +79,6 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
|||||||
Ok(dir.to_string_lossy().to_string())
|
Ok(dir.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开配置文件夹
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
|
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())? {
|
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
@@ -104,7 +100,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 弹出系统目录选择器并返回用户选择的路径
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn pick_directory(
|
pub async fn pick_directory(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -136,14 +131,12 @@ pub async fn pick_directory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取应用配置文件路径
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_app_config_path() -> Result<String, String> {
|
pub async fn get_app_config_path() -> Result<String, String> {
|
||||||
let config_path = config::get_app_config_path();
|
let config_path = config::get_app_config_path();
|
||||||
Ok(config_path.to_string_lossy().to_string())
|
Ok(config_path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开应用配置文件夹
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
||||||
let config_dir = config::get_app_config_dir();
|
let config_dir = config::get_app_config_dir();
|
||||||
@@ -160,7 +153,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_claude_common_config_snippet(
|
pub async fn get_claude_common_config_snippet(
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
@@ -171,13 +163,11 @@ pub async fn get_claude_common_config_snippet(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_claude_common_config_snippet(
|
pub async fn set_claude_common_config_snippet(
|
||||||
snippet: String,
|
snippet: String,
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// 验证是否为有效的 JSON(如果不为空)
|
|
||||||
if !snippet.trim().is_empty() {
|
if !snippet.trim().is_empty() {
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||||
}
|
}
|
||||||
@@ -195,7 +185,6 @@ pub async fn set_claude_common_config_snippet(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取通用配置片段(统一接口)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_common_config_snippet(
|
pub async fn get_common_config_snippet(
|
||||||
app_type: String,
|
app_type: String,
|
||||||
@@ -207,24 +196,43 @@ pub async fn get_common_config_snippet(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置通用配置片段(统一接口)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_common_config_snippet(
|
pub async fn set_common_config_snippet(
|
||||||
app_type: String,
|
app_type: String,
|
||||||
snippet: String,
|
snippet: String,
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// 验证格式(根据应用类型)
|
|
||||||
if !snippet.trim().is_empty() {
|
if !snippet.trim().is_empty() {
|
||||||
match app_type.as_str() {
|
match app_type.as_str() {
|
||||||
"claude" | "gemini" => {
|
"claude" => {
|
||||||
// 验证 JSON 格式
|
// 验证 JSON 格式
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
.map_err(invalid_json_format_error)?;
|
.map_err(invalid_json_format_error)?;
|
||||||
}
|
}
|
||||||
|
"gemini" => {
|
||||||
|
// 验证 ENV/JSON 格式并拒绝禁用键
|
||||||
|
let validation = crate::config_merge::validate_gemini_common_snippet(&snippet);
|
||||||
|
|
||||||
|
// 如果有禁用键,返回错误
|
||||||
|
if !validation.forbidden_keys_found.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"GEMINI_FORBIDDEN_KEYS:{}",
|
||||||
|
validation.forbidden_keys_found.join(",")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果非空但解析后无有效内容,返回错误
|
||||||
|
if !validation.is_valid {
|
||||||
|
return Err("GEMINI_INVALID_SNIPPET".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
"codex" => {
|
"codex" => {
|
||||||
// TOML 格式暂不验证(或可使用 toml crate)
|
// TOML 格式,暂不验证(前端验证)
|
||||||
// 注意:TOML 验证较为复杂,暂时跳过
|
}
|
||||||
|
"omo" => {
|
||||||
|
// 验证 JSON 格式
|
||||||
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
|
.map_err(invalid_json_format_error)?;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -240,14 +248,20 @@ pub async fn set_common_config_snippet(
|
|||||||
.db
|
.db
|
||||||
.set_config_snippet(&app_type, value)
|
.set_config_snippet(&app_type, value)
|
||||||
.map_err(|e| e.to_string())?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提取通用配置片段
|
|
||||||
///
|
|
||||||
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
|
|
||||||
///
|
|
||||||
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn extract_common_config_snippet(
|
pub async fn extract_common_config_snippet(
|
||||||
appType: String,
|
appType: String,
|
||||||
|
|||||||
@@ -109,3 +109,17 @@ pub async fn open_file_dialog<R: tauri::Runtime>(
|
|||||||
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
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()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ pub struct ToolVersion {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||||
let tools = vec!["claude", "codex", "gemini"];
|
let tools = vec!["claude", "codex", "gemini", "opencode"];
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
@@ -116,6 +116,7 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
|||||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||||
|
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,6 +149,29 @@ 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> =
|
static VERSION_RE: Lazy<Regex> =
|
||||||
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
|
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
|
||||||
@@ -224,7 +248,7 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
|||||||
|
|
||||||
// 防御性断言:tool 只能是预定义的值
|
// 防御性断言:tool 只能是预定义的值
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
["claude", "codex", "gemini"].contains(&tool),
|
["claude", "codex", "gemini", "opencode"].contains(&tool),
|
||||||
"unexpected tool name: {tool}"
|
"unexpected tool name: {tool}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -295,11 +319,12 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
|
|
||||||
let home = dirs::home_dir().unwrap_or_default();
|
let home = dirs::home_dir().unwrap_or_default();
|
||||||
|
|
||||||
// 常见的 npm 全局安装路径
|
// 常见的安装路径(原生安装优先)
|
||||||
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
||||||
|
home.join(".local/bin"), // Native install (official recommended)
|
||||||
home.join(".npm-global/bin"),
|
home.join(".npm-global/bin"),
|
||||||
home.join(".local/bin"),
|
home.join("n/bin"), // n version manager
|
||||||
home.join("n/bin"), // n version manager
|
home.join(".volta/bin"), // Volta package manager
|
||||||
];
|
];
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -348,6 +373,14 @@ 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 {
|
for path in &search_paths {
|
||||||
let tool_path = if cfg!(target_os = "windows") {
|
let tool_path = if cfg!(target_os = "windows") {
|
||||||
@@ -405,6 +438,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
|||||||
"claude" => crate::settings::get_claude_override_dir(),
|
"claude" => crate::settings::get_claude_override_dir(),
|
||||||
"codex" => crate::settings::get_codex_override_dir(),
|
"codex" => crate::settings::get_codex_override_dir(),
|
||||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||||
|
"opencode" => crate::settings::get_opencode_override_dir(),
|
||||||
_ => None,
|
_ => None,
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
@@ -619,6 +653,7 @@ exec bash --norc --noprofile
|
|||||||
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
|
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
|
||||||
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||||||
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
"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
|
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -721,7 +756,7 @@ fn launch_macos_open_app(
|
|||||||
|
|
||||||
let output = cmd
|
let output = cmd
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("启动 {} 失败: {e}", app_name))?;
|
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
@@ -921,3 +956,18 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
|
|||||||
|
|
||||||
Ok(())
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ mod global_proxy;
|
|||||||
mod import_export;
|
mod import_export;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod misc;
|
mod misc;
|
||||||
|
mod omo;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
|
mod session_manager;
|
||||||
mod settings;
|
mod settings;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
mod stream_check;
|
mod stream_check;
|
||||||
@@ -25,10 +27,12 @@ pub use global_proxy::*;
|
|||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
|
pub use omo::*;
|
||||||
pub use plugin::*;
|
pub use plugin::*;
|
||||||
pub use prompt::*;
|
pub use prompt::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
pub use proxy::*;
|
pub use proxy::*;
|
||||||
|
pub use session_manager::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use skill::*;
|
pub use skill::*;
|
||||||
pub use stream_check::*;
|
pub use stream_check::*;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, Spee
|
|||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
/// 获取所有供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_providers(
|
pub fn get_providers(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -18,14 +17,12 @@ pub fn get_providers(
|
|||||||
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前供应商ID
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
|
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())?;
|
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())
|
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn add_provider(
|
pub fn add_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -36,7 +33,6 @@ pub fn add_provider(
|
|||||||
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_provider(
|
pub fn update_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -47,7 +43,6 @@ pub fn update_provider(
|
|||||||
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_provider(
|
pub fn delete_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -60,17 +55,18 @@ pub fn delete_provider(
|
|||||||
.map_err(|e| e.to_string())
|
.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]
|
#[tauri::command]
|
||||||
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
|
pub fn remove_provider_from_live_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::remove_from_live_config(app_type, &id)
|
ProviderService::remove_from_live_config(state.inner(), app_type, &id)
|
||||||
.map(|_| true)
|
.map(|_| true)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切换供应商
|
|
||||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
ProviderService::switch(state, app_type, id)
|
ProviderService::switch(state, app_type, id)
|
||||||
}
|
}
|
||||||
@@ -108,14 +104,12 @@ pub fn import_default_config_test_hook(
|
|||||||
import_default_config_internal(state, app_type)
|
import_default_config_internal(state, app_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导入当前配置为默认供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
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())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
import_default_config_internal(&state, app_type).map_err(Into::into)
|
import_default_config_internal(&state, app_type).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询供应商用量
|
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn queryProviderUsage(
|
pub async fn queryProviderUsage(
|
||||||
@@ -129,7 +123,6 @@ pub async fn queryProviderUsage(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
|
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -162,14 +155,12 @@ pub async fn testUsageScript(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取当前生效的配置内容
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
|
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())?;
|
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())
|
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试第三方/自定义供应商端点的网络延迟
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_api_endpoints(
|
pub async fn test_api_endpoints(
|
||||||
urls: Vec<String>,
|
urls: Vec<String>,
|
||||||
@@ -180,7 +171,6 @@ pub async fn test_api_endpoints(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取自定义端点列表
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_custom_endpoints(
|
pub fn get_custom_endpoints(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -192,7 +182,6 @@ pub fn get_custom_endpoints(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加自定义端点
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn add_custom_endpoint(
|
pub fn add_custom_endpoint(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -205,7 +194,6 @@ pub fn add_custom_endpoint(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除自定义端点
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn remove_custom_endpoint(
|
pub fn remove_custom_endpoint(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -218,7 +206,6 @@ pub fn remove_custom_endpoint(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新端点最后使用时间
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_endpoint_last_used(
|
pub fn update_endpoint_last_used(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -231,7 +218,6 @@ pub fn update_endpoint_last_used(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新多个供应商的排序
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_providers_sort_order(
|
pub fn update_providers_sort_order(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -242,24 +228,16 @@ pub fn update_providers_sort_order(
|
|||||||
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 统一供应商(Universal Provider)命令
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
use crate::provider::UniversalProvider;
|
use crate::provider::UniversalProvider;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
/// 统一供应商同步完成事件的 payload
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
#[derive(Clone, serde::Serialize)]
|
||||||
pub struct UniversalProviderSyncedEvent {
|
pub struct UniversalProviderSyncedEvent {
|
||||||
/// 操作类型: "upsert" | "delete" | "sync"
|
|
||||||
pub action: String,
|
pub action: String,
|
||||||
/// 统一供应商 ID
|
|
||||||
pub id: String,
|
pub id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发送统一供应商同步事件,通知前端刷新供应商列表
|
|
||||||
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"universal-provider-synced",
|
"universal-provider-synced",
|
||||||
@@ -270,7 +248,6 @@ fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取所有统一供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_universal_providers(
|
pub fn get_universal_providers(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -278,7 +255,6 @@ pub fn get_universal_providers(
|
|||||||
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
|
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取单个统一供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_universal_provider(
|
pub fn get_universal_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -287,7 +263,6 @@ pub fn get_universal_provider(
|
|||||||
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
|
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加或更新统一供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn upsert_universal_provider(
|
pub fn upsert_universal_provider(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -298,13 +273,11 @@ pub fn upsert_universal_provider(
|
|||||||
let result =
|
let result =
|
||||||
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
|
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 发送事件通知前端刷新
|
|
||||||
emit_universal_provider_synced(&app, "upsert", &id);
|
emit_universal_provider_synced(&app, "upsert", &id);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除统一供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_universal_provider(
|
pub fn delete_universal_provider(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -314,13 +287,11 @@ pub fn delete_universal_provider(
|
|||||||
let result =
|
let result =
|
||||||
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
|
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 发送事件通知前端刷新
|
|
||||||
emit_universal_provider_synced(&app, "delete", &id);
|
emit_universal_provider_synced(&app, "delete", &id);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步统一供应商到各应用(手动触发)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sync_universal_provider(
|
pub fn sync_universal_provider(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -330,29 +301,17 @@ pub fn sync_universal_provider(
|
|||||||
let result =
|
let result =
|
||||||
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
|
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 发送事件通知前端刷新
|
|
||||||
emit_universal_provider_synced(&app, "sync", &id);
|
emit_universal_provider_synced(&app, "sync", &id);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// OpenCode 专属命令
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 从 OpenCode live 配置导入供应商到数据库
|
|
||||||
///
|
|
||||||
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
|
|
||||||
/// 用户可能已经在 opencode.json 中配置了供应商。
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
crate::services::provider::import_opencode_providers_from_live(state.inner())
|
crate::services::provider::import_opencode_providers_from_live(state.inner())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 OpenCode live 配置中的供应商 ID 列表
|
|
||||||
///
|
|
||||||
/// 用于前端判断供应商是否已添加到 opencode.json
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||||
crate::opencode_config::get_providers()
|
crate::opencode_config::get_providers()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 提供前端调用的 API 接口
|
//! 提供前端调用的 API 接口
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
@@ -119,6 +120,120 @@ pub async fn update_proxy_config_for_app(
|
|||||||
.map_err(|e| e.to_string())
|
.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]
|
#[tauri::command]
|
||||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#![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)
|
||||||
|
}
|
||||||
@@ -249,3 +249,16 @@ pub fn remove_skill_repo(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(true)
|
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(¤t_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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,16 +7,25 @@ use crate::error::AppError;
|
|||||||
|
|
||||||
/// 获取用户主目录,带回退和日志
|
/// 获取用户主目录,带回退和日志
|
||||||
///
|
///
|
||||||
/// On Windows, respects the `HOME` environment variable (if set) to support
|
/// ## Windows 注意事项
|
||||||
/// test isolation. Falls back to `dirs::home_dir()` otherwise.
|
///
|
||||||
|
/// - `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 {
|
pub fn get_home_dir() -> PathBuf {
|
||||||
#[cfg(windows)]
|
if let Ok(home) = std::env::var("CC_SWITCH_TEST_HOME") {
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
|
||||||
let trimmed = home.trim();
|
let trimmed = home.trim();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
return PathBuf::from(trimmed);
|
return PathBuf::from(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dirs::home_dir().unwrap_or_else(|| {
|
dirs::home_dir().unwrap_or_else(|| {
|
||||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||||
PathBuf::from(".")
|
PathBuf::from(".")
|
||||||
@@ -81,7 +90,35 @@ pub fn get_app_config_dir() -> PathBuf {
|
|||||||
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
|
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
|
||||||
return custom;
|
return custom;
|
||||||
}
|
}
|
||||||
get_home_dir().join(".cc-switch")
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取应用配置文件路径
|
/// 获取应用配置文件路径
|
||||||
|
|||||||
@@ -0,0 +1,889 @@
|
|||||||
|
//! Configuration merge utilities for the common config redesign.
|
||||||
|
//!
|
||||||
|
//! This module provides functions for:
|
||||||
|
//! - `compute_final_config`: Merges common config (base) with custom config (override)
|
||||||
|
//! - `extract_difference`: Extracts custom parts from live config by comparing with common config
|
||||||
|
//!
|
||||||
|
//! Supports JSON (Claude, Gemini) and TOML (Codex) formats.
|
||||||
|
|
||||||
|
use serde_json::{Map, Value as JsonValue};
|
||||||
|
use toml::Value as TomlValue;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JSON Configuration Merge Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Deep merge two JSON objects where `source` overrides `target`.
|
||||||
|
///
|
||||||
|
/// Merge rules:
|
||||||
|
/// - Nested objects: Recursive merge
|
||||||
|
/// - Arrays: Source completely replaces target (no element-level merge)
|
||||||
|
/// - Primitives: Source overrides target
|
||||||
|
/// - Null values: Do not override
|
||||||
|
fn deep_merge_json(target: &mut JsonValue, source: &JsonValue) {
|
||||||
|
// First check if both are objects without destructuring
|
||||||
|
let both_objects =
|
||||||
|
matches!(target, JsonValue::Object(_)) && matches!(source, JsonValue::Object(_));
|
||||||
|
|
||||||
|
if both_objects {
|
||||||
|
// Safe to destructure now since we know both are objects
|
||||||
|
if let (JsonValue::Object(target_map), JsonValue::Object(source_map)) = (target, source) {
|
||||||
|
for (key, source_value) in source_map {
|
||||||
|
if source_value.is_null() {
|
||||||
|
// Null doesn't override
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match target_map.get_mut(key) {
|
||||||
|
Some(target_value) if target_value.is_object() && source_value.is_object() => {
|
||||||
|
// Nested object: recursive merge
|
||||||
|
deep_merge_json(target_value, source_value);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Other cases: source overrides
|
||||||
|
target_map.insert(key.clone(), source_value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-object: source overrides
|
||||||
|
*target = source.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute final JSON config.
|
||||||
|
///
|
||||||
|
/// Common config as base, custom config overrides (custom takes priority).
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `custom_config` - Provider's custom configuration
|
||||||
|
/// * `common_config` - Common configuration snippet
|
||||||
|
/// * `enabled` - Whether common config is enabled
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// The merged final configuration as JSON value
|
||||||
|
pub fn compute_final_json_config(
|
||||||
|
custom_config: &JsonValue,
|
||||||
|
common_config: &JsonValue,
|
||||||
|
enabled: bool,
|
||||||
|
) -> JsonValue {
|
||||||
|
if !enabled {
|
||||||
|
return custom_config.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate both are objects
|
||||||
|
let common_obj = match common_config {
|
||||||
|
JsonValue::Object(m) if !m.is_empty() => m,
|
||||||
|
_ => return custom_config.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let custom_obj = match custom_config {
|
||||||
|
JsonValue::Object(_) => custom_config,
|
||||||
|
_ => return custom_config.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start with common config as base
|
||||||
|
let mut result = JsonValue::Object(common_obj.clone());
|
||||||
|
|
||||||
|
// Merge custom config on top (custom overrides common)
|
||||||
|
deep_merge_json(&mut result, custom_obj);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if two JSON values are deeply equal.
|
||||||
|
fn json_deep_equal(a: &JsonValue, b: &JsonValue) -> bool {
|
||||||
|
match (a, b) {
|
||||||
|
(JsonValue::Null, JsonValue::Null) => true,
|
||||||
|
(JsonValue::Bool(a), JsonValue::Bool(b)) => a == b,
|
||||||
|
(JsonValue::Number(a), JsonValue::Number(b)) => a == b,
|
||||||
|
(JsonValue::String(a), JsonValue::String(b)) => a == b,
|
||||||
|
(JsonValue::Array(a), JsonValue::Array(b)) => {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
a.iter().zip(b.iter()).all(|(x, y)| json_deep_equal(x, y))
|
||||||
|
}
|
||||||
|
(JsonValue::Object(a), JsonValue::Object(b)) => {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
a.iter()
|
||||||
|
.all(|(k, v)| b.get(k).is_some_and(|bv| json_deep_equal(v, bv)))
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract difference between live config and common config.
|
||||||
|
///
|
||||||
|
/// Extraction rules:
|
||||||
|
/// - Keys not in common config → include in custom config
|
||||||
|
/// - Keys in common config but with different values → include in custom config (user override)
|
||||||
|
/// - Keys in common config with same values → skip (avoid redundant storage)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `live_config` - Configuration read from live file
|
||||||
|
/// * `common_config` - Common configuration snippet
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Tuple of (custom_config, has_common_keys)
|
||||||
|
pub fn extract_json_difference(
|
||||||
|
live_config: &JsonValue,
|
||||||
|
common_config: &JsonValue,
|
||||||
|
) -> (JsonValue, bool) {
|
||||||
|
let live_obj = match live_config {
|
||||||
|
JsonValue::Object(m) => m,
|
||||||
|
_ => return (live_config.clone(), false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let common_obj = match common_config {
|
||||||
|
JsonValue::Object(m) => m,
|
||||||
|
_ => return (live_config.clone(), false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut custom_config = Map::new();
|
||||||
|
let mut has_common_keys = false;
|
||||||
|
|
||||||
|
fn extract_recursive(
|
||||||
|
live: &Map<String, JsonValue>,
|
||||||
|
common: &Map<String, JsonValue>,
|
||||||
|
target: &mut Map<String, JsonValue>,
|
||||||
|
has_common: &mut bool,
|
||||||
|
) {
|
||||||
|
for (key, live_value) in live {
|
||||||
|
match common.get(key) {
|
||||||
|
None => {
|
||||||
|
// Case 1: Key not in common config, keep it
|
||||||
|
target.insert(key.clone(), live_value.clone());
|
||||||
|
}
|
||||||
|
Some(common_value) => {
|
||||||
|
// Check if both are objects for nested handling
|
||||||
|
match (live_value, common_value) {
|
||||||
|
(JsonValue::Object(live_map), JsonValue::Object(common_map)) => {
|
||||||
|
// Case 2: Nested object, recurse
|
||||||
|
let mut nested = Map::new();
|
||||||
|
extract_recursive(live_map, common_map, &mut nested, has_common);
|
||||||
|
if !nested.is_empty() {
|
||||||
|
target.insert(key.clone(), JsonValue::Object(nested));
|
||||||
|
} else {
|
||||||
|
// Nested object matches common config
|
||||||
|
*has_common = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if !json_deep_equal(live_value, common_value) => {
|
||||||
|
// Case 3: Value different, keep it (user override)
|
||||||
|
target.insert(key.clone(), live_value.clone());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Case 4: Value same, skip (avoid redundancy)
|
||||||
|
*has_common = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_recursive(
|
||||||
|
live_obj,
|
||||||
|
common_obj,
|
||||||
|
&mut custom_config,
|
||||||
|
&mut has_common_keys,
|
||||||
|
);
|
||||||
|
|
||||||
|
(JsonValue::Object(custom_config), has_common_keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TOML Configuration Merge Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Deep merge TOML tables while preserving target's key order (custom first, then common).
|
||||||
|
///
|
||||||
|
/// - Keeps target's existing values (target = custom config has priority)
|
||||||
|
/// - Only adds keys from source that don't exist in target (common-only keys)
|
||||||
|
/// - For nested tables, recursively merges while preserving target's keys first
|
||||||
|
fn deep_merge_toml_preserve_order(target: &mut TomlValue, source: &TomlValue) {
|
||||||
|
let both_tables =
|
||||||
|
matches!(target, TomlValue::Table(_)) && matches!(source, TomlValue::Table(_));
|
||||||
|
|
||||||
|
if both_tables {
|
||||||
|
if let (TomlValue::Table(target_map), TomlValue::Table(source_map)) = (target, source) {
|
||||||
|
for (key, source_value) in source_map {
|
||||||
|
match target_map.get_mut(key) {
|
||||||
|
Some(target_value) if target_value.is_table() && source_value.is_table() => {
|
||||||
|
// Nested table: recursive merge (preserving target's keys first)
|
||||||
|
deep_merge_toml_preserve_order(target_value, source_value);
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
// Key exists in target: keep target's value (custom overrides common)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Key only in source: add to target (common-only keys appear after)
|
||||||
|
target_map.insert(key.clone(), source_value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-table case: keep target as-is (custom has priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute final TOML config.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `custom_config` - Provider's custom TOML configuration
|
||||||
|
/// * `common_config` - Common TOML configuration snippet
|
||||||
|
/// * `enabled` - Whether common config is enabled
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Tuple of (final_config_toml, error_message)
|
||||||
|
pub fn compute_final_toml_config_str(
|
||||||
|
custom_toml: &str,
|
||||||
|
common_toml: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> (String, Option<String>) {
|
||||||
|
if !enabled || common_toml.trim().is_empty() {
|
||||||
|
return (custom_toml.to_string(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if common TOML has actual content (not just comments)
|
||||||
|
let common_has_content = common_toml.lines().any(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
!trimmed.is_empty() && !trimmed.starts_with('#')
|
||||||
|
});
|
||||||
|
|
||||||
|
if !common_has_content {
|
||||||
|
return (custom_toml.to_string(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse custom TOML
|
||||||
|
let custom_config: TomlValue = match custom_toml.parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) if custom_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
custom_toml.to_string(),
|
||||||
|
Some(format!("Failed to parse custom TOML: {e}")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse common TOML
|
||||||
|
let common_config: TomlValue = match common_toml.parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
custom_toml.to_string(),
|
||||||
|
Some(format!("Failed to parse common TOML: {e}")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start with custom config (so custom keys appear first in output)
|
||||||
|
let mut result = custom_config;
|
||||||
|
|
||||||
|
// Add common config keys that don't exist in custom (these appear after custom keys)
|
||||||
|
// Custom values always take priority (they're already in result)
|
||||||
|
deep_merge_toml_preserve_order(&mut result, &common_config);
|
||||||
|
|
||||||
|
// Serialize back to TOML string
|
||||||
|
match toml::to_string_pretty(&result) {
|
||||||
|
Ok(s) => (s, None),
|
||||||
|
Err(e) => (
|
||||||
|
custom_toml.to_string(),
|
||||||
|
Some(format!("Failed to serialize TOML: {e}")),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if two TOML values are deeply equal.
|
||||||
|
fn toml_deep_equal(a: &TomlValue, b: &TomlValue) -> bool {
|
||||||
|
match (a, b) {
|
||||||
|
(TomlValue::String(a), TomlValue::String(b)) => a == b,
|
||||||
|
(TomlValue::Integer(a), TomlValue::Integer(b)) => a == b,
|
||||||
|
(TomlValue::Float(a), TomlValue::Float(b)) => (a - b).abs() < f64::EPSILON,
|
||||||
|
(TomlValue::Boolean(a), TomlValue::Boolean(b)) => a == b,
|
||||||
|
(TomlValue::Datetime(a), TomlValue::Datetime(b)) => a == b,
|
||||||
|
(TomlValue::Array(a), TomlValue::Array(b)) => {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
a.iter().zip(b.iter()).all(|(x, y)| toml_deep_equal(x, y))
|
||||||
|
}
|
||||||
|
(TomlValue::Table(a), TomlValue::Table(b)) => {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
a.iter()
|
||||||
|
.all(|(k, v)| b.get(k).is_some_and(|bv| toml_deep_equal(v, bv)))
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract difference between live TOML config and common config.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Tuple of (custom_toml, has_common_keys, error_message)
|
||||||
|
pub fn extract_toml_difference_str(
|
||||||
|
live_toml: &str,
|
||||||
|
common_toml: &str,
|
||||||
|
) -> (String, bool, Option<String>) {
|
||||||
|
if common_toml.trim().is_empty() {
|
||||||
|
return (live_toml.to_string(), false, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if common TOML has actual content
|
||||||
|
let common_has_content = common_toml.lines().any(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
!trimmed.is_empty() && !trimmed.starts_with('#')
|
||||||
|
});
|
||||||
|
|
||||||
|
if !common_has_content {
|
||||||
|
return (live_toml.to_string(), false, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse live TOML
|
||||||
|
let live_config: TomlValue = match live_toml.parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) if live_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
live_toml.to_string(),
|
||||||
|
false,
|
||||||
|
Some(format!("Failed to parse live TOML: {e}")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse common TOML
|
||||||
|
let common_config: TomlValue = match common_toml.parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
live_toml.to_string(),
|
||||||
|
false,
|
||||||
|
Some(format!("Failed to parse common TOML: {e}")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let live_table = match &live_config {
|
||||||
|
TomlValue::Table(m) => m,
|
||||||
|
_ => return (live_toml.to_string(), false, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let common_table = match &common_config {
|
||||||
|
TomlValue::Table(m) => m,
|
||||||
|
_ => return (live_toml.to_string(), false, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut custom_table = toml::map::Map::new();
|
||||||
|
let mut has_common_keys = false;
|
||||||
|
|
||||||
|
fn extract_recursive_toml(
|
||||||
|
live: &toml::map::Map<String, TomlValue>,
|
||||||
|
common: &toml::map::Map<String, TomlValue>,
|
||||||
|
target: &mut toml::map::Map<String, TomlValue>,
|
||||||
|
has_common: &mut bool,
|
||||||
|
) {
|
||||||
|
for (key, live_value) in live {
|
||||||
|
match common.get(key) {
|
||||||
|
None => {
|
||||||
|
target.insert(key.clone(), live_value.clone());
|
||||||
|
}
|
||||||
|
Some(common_value) => match (live_value, common_value) {
|
||||||
|
(TomlValue::Table(live_map), TomlValue::Table(common_map)) => {
|
||||||
|
let mut nested = toml::map::Map::new();
|
||||||
|
extract_recursive_toml(live_map, common_map, &mut nested, has_common);
|
||||||
|
if !nested.is_empty() {
|
||||||
|
target.insert(key.clone(), TomlValue::Table(nested));
|
||||||
|
} else {
|
||||||
|
*has_common = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if !toml_deep_equal(live_value, common_value) => {
|
||||||
|
target.insert(key.clone(), live_value.clone());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
*has_common = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_recursive_toml(
|
||||||
|
live_table,
|
||||||
|
common_table,
|
||||||
|
&mut custom_table,
|
||||||
|
&mut has_common_keys,
|
||||||
|
);
|
||||||
|
|
||||||
|
let custom_config = TomlValue::Table(custom_table);
|
||||||
|
|
||||||
|
match toml::to_string_pretty(&custom_config) {
|
||||||
|
Ok(s) if s.trim().is_empty() => (String::new(), has_common_keys, None),
|
||||||
|
Ok(s) => (s, has_common_keys, None),
|
||||||
|
Err(e) => (
|
||||||
|
live_toml.to_string(),
|
||||||
|
false,
|
||||||
|
Some(format!("Failed to serialize TOML: {e}")),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Live Config Merge for Provider Sync
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::provider::{Provider, ProviderMeta};
|
||||||
|
|
||||||
|
/// Result of merging common config with provider's custom config.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MergeResult {
|
||||||
|
/// The final merged configuration
|
||||||
|
pub config: JsonValue,
|
||||||
|
/// Warning message if any (e.g., parse errors that were recovered from)
|
||||||
|
pub warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if common config is enabled for a provider and app type.
|
||||||
|
///
|
||||||
|
/// Priority:
|
||||||
|
/// 1. `meta.common_config_enabled_by_app.{app_type}` (per-app setting)
|
||||||
|
/// 2. `meta.common_config_enabled` (global setting)
|
||||||
|
/// 3. `false` (default)
|
||||||
|
pub fn is_common_config_enabled(meta: Option<&ProviderMeta>, app_type: &AppType) -> bool {
|
||||||
|
meta.and_then(|m| {
|
||||||
|
m.common_config_enabled_by_app
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|by_app| match app_type {
|
||||||
|
AppType::Claude => by_app.claude,
|
||||||
|
AppType::Codex => by_app.codex,
|
||||||
|
AppType::Gemini => by_app.gemini,
|
||||||
|
AppType::OpenCode => by_app.opencode,
|
||||||
|
})
|
||||||
|
.or(m.common_config_enabled)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge common config with provider's custom config for live file writing.
|
||||||
|
///
|
||||||
|
/// This is the single source of truth for common config merging logic.
|
||||||
|
/// Used by both `live.rs` and `proxy.rs`.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `app_type` - The application type (Claude, Codex, Gemini, OpenCode)
|
||||||
|
/// * `provider` - The provider whose config is being merged
|
||||||
|
/// * `common_snippet` - The common config snippet from database (may be empty)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// `MergeResult` containing the final config and optional warning
|
||||||
|
pub fn merge_config_for_live(
|
||||||
|
app_type: &AppType,
|
||||||
|
provider: &Provider,
|
||||||
|
common_snippet: Option<&str>,
|
||||||
|
) -> MergeResult {
|
||||||
|
// Check if common config is enabled
|
||||||
|
let enabled = is_common_config_enabled(provider.meta.as_ref(), app_type);
|
||||||
|
|
||||||
|
// If not enabled or snippet is empty, return original config
|
||||||
|
let snippet = match common_snippet {
|
||||||
|
Some(s) if enabled && !s.trim().is_empty() => s,
|
||||||
|
_ => {
|
||||||
|
return MergeResult {
|
||||||
|
config: provider.settings_config.clone(),
|
||||||
|
warning: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform merge based on app type
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => merge_claude_config(&provider.settings_config, snippet),
|
||||||
|
AppType::Codex => merge_codex_config(&provider.settings_config, snippet),
|
||||||
|
AppType::Gemini => merge_gemini_config(&provider.settings_config, snippet),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support common config merge
|
||||||
|
MergeResult {
|
||||||
|
config: provider.settings_config.clone(),
|
||||||
|
warning: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge Claude config (JSON format).
|
||||||
|
fn merge_claude_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
|
||||||
|
let common_value: JsonValue = match serde_json::from_str(common_snippet) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
// Return warning without logging - caller will log if needed
|
||||||
|
return MergeResult {
|
||||||
|
config: custom_config.clone(),
|
||||||
|
warning: Some(format!("COMMON_CONFIG_PARSE_ERROR: {e}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MergeResult {
|
||||||
|
config: compute_final_json_config(custom_config, &common_value, true),
|
||||||
|
warning: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge Codex config (TOML for config field, JSON for auth field).
|
||||||
|
fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
|
||||||
|
let mut merged_config = custom_config.clone();
|
||||||
|
let mut warning = None;
|
||||||
|
|
||||||
|
if let Some(obj) = merged_config.as_object_mut() {
|
||||||
|
if let Some(config_str) = obj.get("config").and_then(|v| v.as_str()) {
|
||||||
|
let (merged_toml, error) =
|
||||||
|
compute_final_toml_config_str(config_str, common_snippet, true);
|
||||||
|
if let Some(e) = error {
|
||||||
|
// Return warning without logging - caller will log if needed
|
||||||
|
warning = Some(format!("CODEX_TOML_MERGE_ERROR: {e}"));
|
||||||
|
}
|
||||||
|
obj.insert("config".to_string(), JsonValue::String(merged_toml));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MergeResult {
|
||||||
|
config: merged_config,
|
||||||
|
warning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge Gemini config (JSON format for env field).
|
||||||
|
///
|
||||||
|
/// Gemini common config can be stored in three formats:
|
||||||
|
/// - ENV format: KEY=VALUE lines (one per line)
|
||||||
|
/// - Flat JSON: `{"KEY": "VALUE", ...}`
|
||||||
|
/// - Wrapped JSON: `{"env": {"KEY": "VALUE", ...}}`
|
||||||
|
///
|
||||||
|
/// This function supports all formats for backward compatibility.
|
||||||
|
fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
|
||||||
|
// Parse and validate common config (filters forbidden keys)
|
||||||
|
let validation = validate_gemini_common_snippet(common_snippet);
|
||||||
|
let common_env = validation.env;
|
||||||
|
|
||||||
|
// Generate warning if forbidden keys were found
|
||||||
|
let warning = if !validation.forbidden_keys_found.is_empty() {
|
||||||
|
Some(format!(
|
||||||
|
"GEMINI_FORBIDDEN_KEYS:{}",
|
||||||
|
validation.forbidden_keys_found.join(",")
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if common_env.is_empty() {
|
||||||
|
return MergeResult {
|
||||||
|
config: custom_config.clone(),
|
||||||
|
warning,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut merged_config = custom_config.clone();
|
||||||
|
|
||||||
|
// Merge only the env field
|
||||||
|
// If custom config has env, merge common into it; otherwise initialize with common env
|
||||||
|
if let Some(merged_obj) = merged_config.as_object_mut() {
|
||||||
|
if let Some(merged_env) = merged_obj.get_mut("env") {
|
||||||
|
if let Some(merged_env_obj) = merged_env.as_object_mut() {
|
||||||
|
// Common env as base, custom env overrides
|
||||||
|
let mut final_env = common_env;
|
||||||
|
for (k, v) in merged_env_obj.iter() {
|
||||||
|
final_env.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
*merged_env = JsonValue::Object(final_env);
|
||||||
|
}
|
||||||
|
} else if !common_env.is_empty() {
|
||||||
|
// Custom config has no env field - initialize with common env
|
||||||
|
merged_obj.insert("env".to_string(), JsonValue::Object(common_env));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MergeResult {
|
||||||
|
config: merged_config,
|
||||||
|
warning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Gemini common config snippet supporting multiple formats.
|
||||||
|
///
|
||||||
|
/// Formats supported:
|
||||||
|
/// - ENV format: KEY=VALUE lines
|
||||||
|
/// - Flat JSON: {"KEY": "VALUE", ...}
|
||||||
|
/// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
|
||||||
|
pub(crate) fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map<String, JsonValue> {
|
||||||
|
let trimmed = snippet.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return serde_json::Map::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try JSON first
|
||||||
|
if let Ok(parsed) = serde_json::from_str::<JsonValue>(trimmed) {
|
||||||
|
if let Some(obj) = parsed.as_object() {
|
||||||
|
// Check if it's wrapped format {"env": {...}}
|
||||||
|
if let Some(env_value) = obj.get("env").and_then(|v| v.as_object()) {
|
||||||
|
return env_value.clone();
|
||||||
|
}
|
||||||
|
// Flat format
|
||||||
|
return obj.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as ENV format (KEY=VALUE lines)
|
||||||
|
let mut result = serde_json::Map::new();
|
||||||
|
for line in trimmed.lines() {
|
||||||
|
let line_trimmed = line.trim();
|
||||||
|
if line_trimmed.is_empty() || line_trimmed.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(equal_index) = line_trimmed.find('=') {
|
||||||
|
let key = line_trimmed[..equal_index].trim();
|
||||||
|
let raw_value = line_trimmed[equal_index + 1..].trim();
|
||||||
|
// Strip surrounding quotes (single or double) from value
|
||||||
|
// e.g., KEY="value" or KEY='value' -> value
|
||||||
|
let value = strip_env_quotes(raw_value);
|
||||||
|
if !key.is_empty() {
|
||||||
|
result.insert(key.to_string(), JsonValue::String(value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemini common config forbidden keys - these should never be in common config
|
||||||
|
/// as they are provider-specific credentials/endpoints.
|
||||||
|
const GEMINI_FORBIDDEN_KEYS: &[&str] = &["GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"];
|
||||||
|
|
||||||
|
/// Result of validating Gemini common config snippet
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GeminiSnippetValidation {
|
||||||
|
/// Parsed environment variables (with forbidden keys filtered out)
|
||||||
|
pub env: serde_json::Map<String, JsonValue>,
|
||||||
|
/// List of forbidden keys that were found and filtered
|
||||||
|
pub forbidden_keys_found: Vec<String>,
|
||||||
|
/// Whether the snippet is valid (parseable and non-empty after filtering)
|
||||||
|
pub is_valid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse and validate Gemini common config snippet.
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Parses the snippet (supports ENV/JSON formats)
|
||||||
|
/// 2. Filters out forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY)
|
||||||
|
/// 3. Returns validation result with filtered env and forbidden keys found
|
||||||
|
pub fn validate_gemini_common_snippet(snippet: &str) -> GeminiSnippetValidation {
|
||||||
|
let raw_env = parse_gemini_common_snippet(snippet);
|
||||||
|
let mut filtered_env = serde_json::Map::new();
|
||||||
|
let mut forbidden_keys_found = Vec::new();
|
||||||
|
|
||||||
|
for (key, value) in raw_env {
|
||||||
|
if GEMINI_FORBIDDEN_KEYS.contains(&key.as_str()) {
|
||||||
|
forbidden_keys_found.push(key);
|
||||||
|
} else {
|
||||||
|
filtered_env.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GeminiSnippetValidation {
|
||||||
|
is_valid: !filtered_env.is_empty() || snippet.trim().is_empty(),
|
||||||
|
env: filtered_env,
|
||||||
|
forbidden_keys_found,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip surrounding quotes from ENV value.
|
||||||
|
/// Supports both single and double quotes: "value" -> value, 'value' -> value
|
||||||
|
fn strip_env_quotes(s: &str) -> &str {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
if bytes.len() >= 2 {
|
||||||
|
let first = bytes[0];
|
||||||
|
let last = bytes[bytes.len() - 1];
|
||||||
|
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
||||||
|
return &s[1..s.len() - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Unit Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_final_json_config_disabled() {
|
||||||
|
let custom = json!({"a": 1, "b": 2});
|
||||||
|
let common = json!({"c": 3});
|
||||||
|
|
||||||
|
let result = compute_final_json_config(&custom, &common, false);
|
||||||
|
assert_eq!(result, custom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_final_json_config_enabled() {
|
||||||
|
let custom = json!({"a": 1, "b": 2});
|
||||||
|
let common = json!({"b": 99, "c": 3});
|
||||||
|
|
||||||
|
let result = compute_final_json_config(&custom, &common, true);
|
||||||
|
|
||||||
|
// custom overrides common, so b should be 2
|
||||||
|
assert_eq!(result["a"], 1);
|
||||||
|
assert_eq!(result["b"], 2); // custom wins
|
||||||
|
assert_eq!(result["c"], 3); // from common
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_final_json_config_nested() {
|
||||||
|
let custom = json!({
|
||||||
|
"env": {
|
||||||
|
"API_KEY": "custom-key",
|
||||||
|
"CUSTOM_VAR": "value"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let common = json!({
|
||||||
|
"env": {
|
||||||
|
"API_KEY": "common-key",
|
||||||
|
"SHARED_VAR": "shared"
|
||||||
|
},
|
||||||
|
"includeCoAuthoredBy": false
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = compute_final_json_config(&custom, &common, true);
|
||||||
|
|
||||||
|
assert_eq!(result["env"]["API_KEY"], "custom-key"); // custom wins
|
||||||
|
assert_eq!(result["env"]["CUSTOM_VAR"], "value"); // from custom
|
||||||
|
assert_eq!(result["env"]["SHARED_VAR"], "shared"); // from common
|
||||||
|
assert_eq!(result["includeCoAuthoredBy"], false); // from common
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_json_difference() {
|
||||||
|
let live = json!({
|
||||||
|
"env": {
|
||||||
|
"API_KEY": "my-key",
|
||||||
|
"SHARED_VAR": "shared"
|
||||||
|
},
|
||||||
|
"includeCoAuthoredBy": false,
|
||||||
|
"custom_field": true
|
||||||
|
});
|
||||||
|
let common = json!({
|
||||||
|
"env": {
|
||||||
|
"SHARED_VAR": "shared"
|
||||||
|
},
|
||||||
|
"includeCoAuthoredBy": false
|
||||||
|
});
|
||||||
|
|
||||||
|
let (custom, has_common) = extract_json_difference(&live, &common);
|
||||||
|
|
||||||
|
// Should keep API_KEY (not in common) and custom_field
|
||||||
|
assert_eq!(custom["env"]["API_KEY"], "my-key");
|
||||||
|
assert_eq!(custom["custom_field"], true);
|
||||||
|
// Should NOT have SHARED_VAR or includeCoAuthoredBy (same as common)
|
||||||
|
assert!(custom["env"].get("SHARED_VAR").is_none());
|
||||||
|
assert!(custom.get("includeCoAuthoredBy").is_none());
|
||||||
|
assert!(has_common);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_json_difference_with_override() {
|
||||||
|
let live = json!({
|
||||||
|
"includeCoAuthoredBy": true, // Different from common!
|
||||||
|
"shared": "value"
|
||||||
|
});
|
||||||
|
let common = json!({
|
||||||
|
"includeCoAuthoredBy": false,
|
||||||
|
"shared": "value"
|
||||||
|
});
|
||||||
|
|
||||||
|
let (custom, has_common) = extract_json_difference(&live, &common);
|
||||||
|
|
||||||
|
// Should keep includeCoAuthoredBy because value is different
|
||||||
|
assert_eq!(custom["includeCoAuthoredBy"], true);
|
||||||
|
// Should NOT have shared (same as common)
|
||||||
|
assert!(custom.get("shared").is_none());
|
||||||
|
assert!(has_common);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_final_toml_config() {
|
||||||
|
let custom = r#"
|
||||||
|
model = "custom-model"
|
||||||
|
[custom_section]
|
||||||
|
key = "value"
|
||||||
|
"#;
|
||||||
|
let common = r#"
|
||||||
|
model = "common-model"
|
||||||
|
shared_key = "shared"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let (result, error) = compute_final_toml_config_str(custom, common, true);
|
||||||
|
|
||||||
|
assert!(error.is_none());
|
||||||
|
assert!(result.contains("custom-model")); // custom wins
|
||||||
|
assert!(result.contains("shared_key")); // from common
|
||||||
|
|
||||||
|
// With preserve_order feature enabled, verify key ordering via parsing
|
||||||
|
// instead of relying on string position (which is fragile)
|
||||||
|
let parsed: TomlValue = result.parse().expect("result should be valid TOML");
|
||||||
|
let table = parsed.as_table().expect("result should be a table");
|
||||||
|
let keys: Vec<&String> = table.keys().collect();
|
||||||
|
|
||||||
|
// Custom keys should appear before common-only keys
|
||||||
|
let model_idx = keys.iter().position(|k| *k == "model");
|
||||||
|
let custom_section_idx = keys.iter().position(|k| *k == "custom_section");
|
||||||
|
let shared_key_idx = keys.iter().position(|k| *k == "shared_key");
|
||||||
|
|
||||||
|
assert!(model_idx.is_some(), "model key should exist in result");
|
||||||
|
assert!(
|
||||||
|
custom_section_idx.is_some(),
|
||||||
|
"custom_section key should exist in result"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shared_key_idx.is_some(),
|
||||||
|
"shared_key key should exist in result"
|
||||||
|
);
|
||||||
|
|
||||||
|
// With preserve_order, custom keys (model, custom_section) should come before common-only keys (shared_key)
|
||||||
|
assert!(
|
||||||
|
model_idx.unwrap() < shared_key_idx.unwrap(),
|
||||||
|
"custom 'model' should appear before common-only 'shared_key' (got model_idx={:?}, shared_key_idx={:?})",
|
||||||
|
model_idx, shared_key_idx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_toml_difference() {
|
||||||
|
let live = r#"
|
||||||
|
model = "my-model"
|
||||||
|
shared_key = "shared"
|
||||||
|
[custom_section]
|
||||||
|
key = "value"
|
||||||
|
"#;
|
||||||
|
let common = r#"
|
||||||
|
shared_key = "shared"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let (custom, has_common, error) = extract_toml_difference_str(live, common);
|
||||||
|
|
||||||
|
assert!(error.is_none());
|
||||||
|
assert!(custom.contains("model")); // not in common
|
||||||
|
assert!(custom.contains("custom_section")); // not in common
|
||||||
|
assert!(!custom.contains("shared_key")); // same as common
|
||||||
|
assert!(has_common);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
pub mod failover;
|
pub mod failover;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod omo;
|
||||||
pub mod prompts;
|
pub mod prompts;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
@@ -15,3 +16,4 @@ pub mod universal_providers;
|
|||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
// 导出 FailoverQueueItem 供外部使用
|
// 导出 FailoverQueueItem 供外部使用
|
||||||
pub use failover::FailoverQueueItem;
|
pub use failover::FailoverQueueItem;
|
||||||
|
pub use omo::OmoGlobalConfig;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
//! 供应商数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供供应商(Provider)的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
use crate::database::{lock_conn, Database};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::{Provider, ProviderMeta};
|
use crate::provider::{Provider, ProviderMeta};
|
||||||
@@ -9,8 +5,18 @@ use indexmap::IndexMap;
|
|||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
type OmoProviderRow = (
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
|
Option<usize>,
|
||||||
|
Option<String>,
|
||||||
|
String,
|
||||||
|
);
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 获取指定应用类型的所有供应商
|
|
||||||
pub fn get_all_providers(
|
pub fn get_all_providers(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
@@ -66,7 +72,6 @@ impl Database {
|
|||||||
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
|
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
provider.id = id.clone();
|
provider.id = id.clone();
|
||||||
|
|
||||||
// 加载 endpoints
|
|
||||||
let mut stmt_endpoints = conn.prepare(
|
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"
|
"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()))?;
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -103,7 +108,6 @@ impl Database {
|
|||||||
Ok(providers)
|
Ok(providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前激活的供应商 ID
|
|
||||||
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
@@ -123,7 +127,6 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据 ID 获取单个供应商
|
|
||||||
pub fn get_provider_by_id(
|
pub fn get_provider_by_id(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -174,21 +177,15 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存供应商(新增或更新)
|
|
||||||
///
|
|
||||||
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
|
||||||
/// (add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
|
|
||||||
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
|
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
|
||||||
let mut conn = lock_conn!(self.conn);
|
let mut conn = lock_conn!(self.conn);
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 处理 meta:取出 endpoints 以便单独处理
|
|
||||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||||
|
|
||||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
|
||||||
let existing: Option<(bool, bool)> = tx
|
let existing: Option<(bool, bool)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
@@ -202,7 +199,6 @@ impl Database {
|
|||||||
existing.unwrap_or((false, provider.in_failover_queue));
|
existing.unwrap_or((false, provider.in_failover_queue));
|
||||||
|
|
||||||
if is_update {
|
if is_update {
|
||||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE providers SET
|
"UPDATE providers SET
|
||||||
name = ?1,
|
name = ?1,
|
||||||
@@ -241,7 +237,6 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
} else {
|
} else {
|
||||||
// 新增模式:使用 INSERT
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO providers (
|
"INSERT INTO providers (
|
||||||
id, app_type, name, settings_config, website_url, category,
|
id, app_type, name, settings_config, website_url, category,
|
||||||
@@ -268,7 +263,6 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 只有新增时才同步 endpoints
|
|
||||||
for (url, endpoint) in endpoints {
|
for (url, endpoint) in endpoints {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
||||||
@@ -283,7 +277,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除供应商
|
|
||||||
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -294,21 +287,18 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置当前供应商
|
|
||||||
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||||
let mut conn = lock_conn!(self.conn);
|
let mut conn = lock_conn!(self.conn);
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 重置所有为 0
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
|
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
|
||||||
params![app_type],
|
params![app_type],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 设置新的当前供应商
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
|
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
|
||||||
params![id, app_type],
|
params![id, app_type],
|
||||||
@@ -319,7 +309,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
|
|
||||||
pub fn update_provider_settings_config(
|
pub fn update_provider_settings_config(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
@@ -341,7 +330,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加自定义端点
|
|
||||||
pub fn add_custom_endpoint(
|
pub fn add_custom_endpoint(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
@@ -357,7 +345,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 移除自定义端点
|
|
||||||
pub fn remove_custom_endpoint(
|
pub fn remove_custom_endpoint(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
@@ -372,4 +359,126 @@ impl Database {
|
|||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
use super::super::{lock_conn, Database};
|
use super::super::{lock_conn, Database};
|
||||||
|
|
||||||
@@ -75,6 +76,117 @@ impl Database {
|
|||||||
Ok(())
|
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(
|
pub async fn get_proxy_config_for_app(
|
||||||
&self,
|
&self,
|
||||||
@@ -177,17 +289,90 @@ impl Database {
|
|||||||
Ok(())
|
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 表的三行数据
|
/// 初始化 proxy_config 表的三行数据
|
||||||
|
///
|
||||||
|
/// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||||
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
for app_type in &["claude", "codex", "gemini"] {
|
// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||||
conn.execute(
|
// claude: 更激进的重试和超时配置
|
||||||
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
|
conn.execute(
|
||||||
[app_type],
|
"INSERT OR IGNORE INTO proxy_config (
|
||||||
)
|
app_type, max_retries,
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
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()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -662,3 +847,70 @@ impl Database {
|
|||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ impl Database {
|
|||||||
|
|
||||||
/// 获取整流器配置
|
/// 获取整流器配置
|
||||||
///
|
///
|
||||||
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
/// 返回整流器配置,如果不存在则返回默认值(全部开启)
|
||||||
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
||||||
match self.get_setting("rectifier_config")? {
|
match self.get_setting("rectifier_config")? {
|
||||||
Some(json) => serde_json::from_str(&json)
|
Some(json) => serde_json::from_str(&json)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ mod tests;
|
|||||||
|
|
||||||
// DAO 类型导出供外部使用
|
// DAO 类型导出供外部使用
|
||||||
pub use dao::FailoverQueueItem;
|
pub use dao::FailoverQueueItem;
|
||||||
|
pub use dao::OmoGlobalConfig;
|
||||||
|
|
||||||
use crate::config::get_app_config_dir;
|
use crate::config::get_app_config_dir;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -47,7 +48,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
|||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 4;
|
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||||
|
|
||||||
/// 安全地序列化 JSON,避免 unwrap panic
|
/// 安全地序列化 JSON,避免 unwrap panic
|
||||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ impl Database {
|
|||||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
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_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,
|
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'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
@@ -170,6 +172,7 @@ impl Database {
|
|||||||
// 10. Proxy Request Logs 表
|
// 10. Proxy Request Logs 表
|
||||||
conn.execute("CREATE TABLE IF NOT EXISTS 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_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,
|
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,
|
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',
|
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
@@ -352,6 +355,11 @@ impl Database {
|
|||||||
Self::migrate_v3_to_v4(conn)?;
|
Self::migrate_v3_to_v4(conn)?;
|
||||||
Self::set_user_version(conn, 4)?;
|
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!(
|
return Err(AppError::Database(format!(
|
||||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||||
@@ -521,6 +529,7 @@ impl Database {
|
|||||||
// proxy_request_logs 表
|
// proxy_request_logs 表
|
||||||
conn.execute("CREATE TABLE IF NOT EXISTS 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_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,
|
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,
|
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',
|
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
@@ -677,6 +686,8 @@ impl Database {
|
|||||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
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_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,
|
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'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)", [])?;
|
)", [])?;
|
||||||
|
|
||||||
@@ -879,12 +890,45 @@ impl Database {
|
|||||||
Ok(())
|
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, display_name, input, output, cache_read, cache_creation)
|
||||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||||
let pricing_data = [
|
let pricing_data = [
|
||||||
// Claude 4.5 系列 (Latest Models)
|
// Claude 4.6 系列
|
||||||
|
(
|
||||||
|
"claude-opus-4-6-20260206",
|
||||||
|
"Claude Opus 4.6",
|
||||||
|
"5",
|
||||||
|
"25",
|
||||||
|
"0.50",
|
||||||
|
"6.25",
|
||||||
|
),
|
||||||
|
// Claude 4.5 系列
|
||||||
(
|
(
|
||||||
"claude-opus-4-5-20251101",
|
"claude-opus-4-5-20251101",
|
||||||
"Claude Opus 4.5",
|
"Claude Opus 4.5",
|
||||||
@@ -990,6 +1034,40 @@ impl Database {
|
|||||||
"0.175",
|
"0.175",
|
||||||
"0",
|
"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", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
("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"),
|
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||||
@@ -1177,7 +1255,7 @@ impl Database {
|
|||||||
|
|
||||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO model_pricing (
|
"INSERT OR IGNORE INTO model_pricing (
|
||||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
@@ -1204,14 +1282,8 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||||||
let count: i64 = conn
|
// 每次启动都执行 INSERT OR IGNORE,增量追加新模型,已有数据不覆盖
|
||||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
Self::seed_model_pricing(conn)
|
||||||
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
|
|
||||||
|
|
||||||
if count == 0 {
|
|
||||||
Self::seed_model_pricing(conn)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 辅助方法 ---
|
// --- 辅助方法 ---
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_sets_user_version_when_missing() {
|
fn schema_migration_sets_user_version_when_missing() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||||
@@ -169,7 +169,7 @@ fn migration_sets_user_version_when_missing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_rejects_future_version() {
|
fn schema_migration_rejects_future_version() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||||
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
||||||
@@ -183,7 +183,7 @@ fn migration_rejects_future_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_adds_missing_columns_for_providers() {
|
fn schema_migration_adds_missing_columns_for_providers() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
// 创建旧版 providers 表,缺少新增列
|
// 创建旧版 providers 表,缺少新增列
|
||||||
@@ -224,7 +224,7 @@ fn migration_adds_missing_columns_for_providers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_aligns_column_defaults_and_types() {
|
fn schema_migration_aligns_column_defaults_and_types() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||||||
.expect("seed old schema");
|
.expect("seed old schema");
|
||||||
@@ -268,7 +268,67 @@ fn migration_aligns_column_defaults_and_types() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
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() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
// 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type)
|
// 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type)
|
||||||
@@ -433,7 +493,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dry_run_does_not_write_to_disk() {
|
fn schema_dry_run_does_not_write_to_disk() {
|
||||||
// Create minimal valid config for migration
|
// Create minimal valid config for migration
|
||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
@@ -507,7 +567,7 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_pricing_is_seeded_on_init() {
|
fn schema_model_pricing_is_seeded_on_init() {
|
||||||
let db = Database::memory().expect("create memory db");
|
let db = Database::memory().expect("create memory db");
|
||||||
|
|
||||||
let conn = db.conn.lock().expect("lock conn");
|
let conn = db.conn.lock().expect("lock conn");
|
||||||
|
|||||||
@@ -180,58 +180,68 @@ fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build provider meta with usage script configuration
|
/// Build provider meta with usage script configuration
|
||||||
|
///
|
||||||
|
/// Note: Deeplink imported providers have common config disabled by default
|
||||||
|
/// to avoid unexpected configuration merging.
|
||||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||||
// Check if any usage script fields are provided
|
// Check if any usage script fields are provided
|
||||||
if request.usage_script.is_none()
|
let has_usage_fields = request.usage_script.is_some()
|
||||||
&& request.usage_enabled.is_none()
|
|| request.usage_enabled.is_some()
|
||||||
&& request.usage_api_key.is_none()
|
|| request.usage_api_key.is_some()
|
||||||
&& request.usage_base_url.is_none()
|
|| request.usage_base_url.is_some()
|
||||||
&& request.usage_access_token.is_none()
|
|| request.usage_access_token.is_some()
|
||||||
&& request.usage_user_id.is_none()
|
|| request.usage_user_id.is_some()
|
||||||
&& request.usage_auto_interval.is_none()
|
|| request.usage_auto_interval.is_some();
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode usage script code if provided
|
// Build usage script if fields are provided
|
||||||
let code = if let Some(script_b64) = &request.usage_script {
|
let usage_script = if has_usage_fields {
|
||||||
let decoded = decode_base64_param("usage_script", script_b64)?;
|
// Decode usage script code if provided
|
||||||
String::from_utf8(decoded)
|
let code = if let Some(script_b64) = &request.usage_script {
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
|
let decoded = decode_base64_param("usage_script", script_b64)?;
|
||||||
|
String::from_utf8(decoded).map_err(|e| {
|
||||||
|
AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}"))
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine enabled state: explicit param > has code > false
|
||||||
|
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||||
|
|
||||||
|
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||||
|
// Note: use primary endpoint only (first one if comma-separated)
|
||||||
|
Some(UsageScript {
|
||||||
|
enabled,
|
||||||
|
language: "javascript".to_string(),
|
||||||
|
code,
|
||||||
|
timeout: Some(10),
|
||||||
|
api_key: request
|
||||||
|
.usage_api_key
|
||||||
|
.clone()
|
||||||
|
.or_else(|| request.api_key.clone()),
|
||||||
|
base_url: request.usage_base_url.clone().or_else(|| {
|
||||||
|
let primary = get_primary_endpoint(request);
|
||||||
|
if primary.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(primary)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
access_token: request.usage_access_token.clone(),
|
||||||
|
user_id: request.usage_user_id.clone(),
|
||||||
|
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||||
|
auto_query_interval: request.usage_auto_interval,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
None
|
||||||
};
|
|
||||||
|
|
||||||
// Determine enabled state: explicit param > has code > false
|
|
||||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
|
||||||
|
|
||||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
|
||||||
// Note: use primary endpoint only (first one if comma-separated)
|
|
||||||
let usage_script = UsageScript {
|
|
||||||
enabled,
|
|
||||||
language: "javascript".to_string(),
|
|
||||||
code,
|
|
||||||
timeout: Some(10),
|
|
||||||
api_key: request
|
|
||||||
.usage_api_key
|
|
||||||
.clone()
|
|
||||||
.or_else(|| request.api_key.clone()),
|
|
||||||
base_url: request.usage_base_url.clone().or_else(|| {
|
|
||||||
let primary = get_primary_endpoint(request);
|
|
||||||
if primary.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(primary)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
access_token: request.usage_access_token.clone(),
|
|
||||||
user_id: request.usage_user_id.clone(),
|
|
||||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
|
||||||
auto_query_interval: request.usage_auto_interval,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Always return a ProviderMeta with common_config_enabled = false for deeplink imports
|
||||||
|
// This ensures the imported provider uses its own settings_config directly
|
||||||
|
// without merging with the global common config snippet
|
||||||
Ok(Some(ProviderMeta {
|
Ok(Some(ProviderMeta {
|
||||||
usage_script: Some(usage_script),
|
usage_script,
|
||||||
|
common_config_enabled: Some(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ pub enum AppError {
|
|||||||
},
|
},
|
||||||
#[error("数据库错误: {0}")]
|
#[error("数据库错误: {0}")]
|
||||||
Database(String),
|
Database(String),
|
||||||
|
#[error("OMO 配置文件不存在")]
|
||||||
|
OmoConfigNotFound,
|
||||||
#[error("所有供应商已熔断,无可用渠道")]
|
#[error("所有供应商已熔断,无可用渠道")]
|
||||||
AllProvidersCircuitOpen,
|
AllProvidersCircuitOpen,
|
||||||
#[error("未配置供应商")]
|
#[error("未配置供应商")]
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -7,14 +6,6 @@ use crate::config::atomic_write;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::gemini_config::get_gemini_settings_path;
|
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)
|
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json)
|
||||||
fn user_config_path() -> PathBuf {
|
fn user_config_path() -> PathBuf {
|
||||||
get_gemini_settings_path()
|
get_gemini_settings_path()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod claude_plugin;
|
|||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod config_merge;
|
||||||
mod database;
|
mod database;
|
||||||
mod deeplink;
|
mod deeplink;
|
||||||
mod error;
|
mod error;
|
||||||
@@ -21,6 +22,7 @@ mod provider;
|
|||||||
mod provider_defaults;
|
mod provider_defaults;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod services;
|
mod services;
|
||||||
|
mod session_manager;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod store;
|
mod store;
|
||||||
mod tray;
|
mod tray;
|
||||||
@@ -502,6 +504,28 @@ pub fn run() {
|
|||||||
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
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 服务器配置(表空时触发)
|
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||||
log::info!("MCP table empty, importing from live configurations...");
|
log::info!("MCP table empty, importing from live configurations...");
|
||||||
@@ -745,6 +769,21 @@ pub fn run() {
|
|||||||
restore_proxy_state_on_startup(&state).await;
|
restore_proxy_state_on_startup(&state).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.with_webview(|webview| {
|
||||||
|
use webkit2gtk::{WebViewExt, SettingsExt, HardwareAccelerationPolicy};
|
||||||
|
let wk_webview = webview.inner();
|
||||||
|
if let Some(settings) = WebViewExt::settings(&wk_webview) {
|
||||||
|
SettingsExt::set_hardware_acceleration_policy(&settings, HardwareAccelerationPolicy::Never);
|
||||||
|
log::info!("已禁用 WebKitGTK 硬件加速");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 静默启动:根据设置决定是否显示主窗口
|
// 静默启动:根据设置决定是否显示主窗口
|
||||||
let settings = crate::settings::get_settings();
|
let settings = crate::settings::get_settings();
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
@@ -850,6 +889,7 @@ pub fn run() {
|
|||||||
commands::import_config_from_file,
|
commands::import_config_from_file,
|
||||||
commands::save_file_dialog,
|
commands::save_file_dialog,
|
||||||
commands::open_file_dialog,
|
commands::open_file_dialog,
|
||||||
|
commands::open_zip_file_dialog,
|
||||||
commands::sync_current_providers_live,
|
commands::sync_current_providers_live,
|
||||||
// Deep link import
|
// Deep link import
|
||||||
commands::parse_deeplink,
|
commands::parse_deeplink,
|
||||||
@@ -879,6 +919,7 @@ pub fn run() {
|
|||||||
commands::get_skill_repos,
|
commands::get_skill_repos,
|
||||||
commands::add_skill_repo,
|
commands::add_skill_repo,
|
||||||
commands::remove_skill_repo,
|
commands::remove_skill_repo,
|
||||||
|
commands::install_skills_from_zip,
|
||||||
// Auto launch
|
// Auto launch
|
||||||
commands::set_auto_launch,
|
commands::set_auto_launch,
|
||||||
commands::get_auto_launch_status,
|
commands::get_auto_launch_status,
|
||||||
@@ -895,6 +936,10 @@ pub fn run() {
|
|||||||
commands::update_global_proxy_config,
|
commands::update_global_proxy_config,
|
||||||
commands::get_proxy_config_for_app,
|
commands::get_proxy_config_for_app,
|
||||||
commands::update_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_proxy_running,
|
||||||
commands::is_live_takeover_active,
|
commands::is_live_takeover_active,
|
||||||
commands::switch_proxy_provider,
|
commands::switch_proxy_provider,
|
||||||
@@ -927,6 +972,10 @@ pub fn run() {
|
|||||||
commands::stream_check_all_providers,
|
commands::stream_check_all_providers,
|
||||||
commands::get_stream_check_config,
|
commands::get_stream_check_config,
|
||||||
commands::save_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,
|
commands::get_tool_versions,
|
||||||
// Provider terminal
|
// Provider terminal
|
||||||
commands::open_provider_terminal,
|
commands::open_provider_terminal,
|
||||||
@@ -945,6 +994,12 @@ pub fn run() {
|
|||||||
commands::test_proxy_url,
|
commands::test_proxy_url,
|
||||||
commands::get_upstream_proxy_status,
|
commands::get_upstream_proxy_status,
|
||||||
commands::scan_local_proxies,
|
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
|
let app = builder
|
||||||
|
|||||||
@@ -1,26 +1,3 @@
|
|||||||
//! 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::config::write_json_file;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::OpenCodeProviderConfig;
|
use crate::provider::OpenCodeProviderConfig;
|
||||||
@@ -29,52 +6,29 @@ use indexmap::IndexMap;
|
|||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Path Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 获取 OpenCode 配置目录
|
|
||||||
///
|
|
||||||
/// 默认路径: `~/.config/opencode/`
|
|
||||||
/// 可通过 settings.opencode_config_dir 覆盖
|
|
||||||
pub fn get_opencode_dir() -> PathBuf {
|
pub fn get_opencode_dir() -> PathBuf {
|
||||||
if let Some(override_dir) = get_opencode_override_dir() {
|
if let Some(override_dir) = get_opencode_override_dir() {
|
||||||
return override_dir;
|
return override_dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有平台统一使用 ~/.config/opencode
|
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.map(|h| h.join(".config").join("opencode"))
|
.map(|h| h.join(".config").join("opencode"))
|
||||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 OpenCode 配置文件路径
|
|
||||||
///
|
|
||||||
/// 返回 `~/.config/opencode/opencode.json`
|
|
||||||
pub fn get_opencode_config_path() -> PathBuf {
|
pub fn get_opencode_config_path() -> PathBuf {
|
||||||
get_opencode_dir().join("opencode.json")
|
get_opencode_dir().join("opencode.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 OpenCode 环境变量文件路径(如果存在)
|
|
||||||
///
|
|
||||||
/// 返回 `~/.config/opencode/.env`
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn get_opencode_env_path() -> PathBuf {
|
pub fn get_opencode_env_path() -> PathBuf {
|
||||||
get_opencode_dir().join(".env")
|
get_opencode_dir().join(".env")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Core Read/Write Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 读取 OpenCode 配置文件
|
|
||||||
///
|
|
||||||
/// 返回完整的配置 JSON 对象
|
|
||||||
pub fn read_opencode_config() -> Result<Value, AppError> {
|
pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||||
let path = get_opencode_config_path();
|
let path = get_opencode_config_path();
|
||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
// Return empty config with schema
|
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"$schema": "https://opencode.ai/config.json"
|
"$schema": "https://opencode.ai/config.json"
|
||||||
}));
|
}));
|
||||||
@@ -84,23 +38,14 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
|||||||
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写入 OpenCode 配置文件(原子写入)
|
|
||||||
///
|
|
||||||
/// 使用临时文件 + 重命名确保原子性
|
|
||||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||||
let path = get_opencode_config_path();
|
let path = get_opencode_config_path();
|
||||||
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
|
|
||||||
write_json_file(&path, config)?;
|
write_json_file(&path, config)?;
|
||||||
|
|
||||||
log::debug!("OpenCode config written to {path:?}");
|
log::debug!("OpenCode config written to {path:?}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Provider Functions (Untyped - for raw JSON operations)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 获取所有供应商配置(原始 JSON)
|
|
||||||
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||||
let config = read_opencode_config()?;
|
let config = read_opencode_config()?;
|
||||||
Ok(config
|
Ok(config
|
||||||
@@ -110,7 +55,6 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
|||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置供应商配置(原始 JSON)
|
|
||||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
let mut full_config = read_opencode_config()?;
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
@@ -128,7 +72,6 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
|||||||
write_opencode_config(&full_config)
|
write_opencode_config(&full_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除供应商配置
|
|
||||||
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||||
let mut config = read_opencode_config()?;
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
@@ -139,11 +82,6 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
|||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Provider Functions (Typed - using OpenCodeProviderConfig)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 获取所有供应商配置(类型化)
|
|
||||||
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
||||||
let providers = get_providers()?;
|
let providers = get_providers()?;
|
||||||
let mut result = IndexMap::new();
|
let mut result = IndexMap::new();
|
||||||
@@ -155,7 +93,6 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Failed to parse provider '{id}': {e}");
|
log::warn!("Failed to parse provider '{id}': {e}");
|
||||||
// Skip invalid providers but continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,17 +100,11 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置供应商配置(类型化)
|
|
||||||
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
|
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 })?;
|
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
set_provider(id, value)
|
set_provider(id, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MCP Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 获取所有 MCP 服务器配置
|
|
||||||
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
||||||
let config = read_opencode_config()?;
|
let config = read_opencode_config()?;
|
||||||
Ok(config
|
Ok(config
|
||||||
@@ -183,7 +114,6 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
|||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 MCP 服务器配置
|
|
||||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
let mut full_config = read_opencode_config()?;
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
@@ -198,7 +128,6 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
|||||||
write_opencode_config(&full_config)
|
write_opencode_config(&full_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除 MCP 服务器配置
|
|
||||||
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||||
let mut config = read_opencode_config()?;
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
@@ -208,3 +137,57 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
write_opencode_config(&config)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -191,6 +191,19 @@ pub struct ProviderProxyConfig {
|
|||||||
pub proxy_password: Option<String>,
|
pub proxy_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 通用配置启用状态(按应用)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct CommonConfigEnabledByApp {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub claude: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub codex: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gemini: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opencode: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 供应商元数据
|
/// 供应商元数据
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderMeta {
|
pub struct ProviderMeta {
|
||||||
@@ -215,6 +228,9 @@ pub struct ProviderMeta {
|
|||||||
/// 成本倍数(用于计算实际成本)
|
/// 成本倍数(用于计算实际成本)
|
||||||
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
|
||||||
pub cost_multiplier: Option<String>,
|
pub cost_multiplier: Option<String>,
|
||||||
|
/// 计费模式来源(response/request)
|
||||||
|
#[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pricing_model_source: Option<String>,
|
||||||
/// 每日消费限额(USD)
|
/// 每日消费限额(USD)
|
||||||
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
|
||||||
pub limit_daily_usd: Option<String>,
|
pub limit_daily_usd: Option<String>,
|
||||||
@@ -227,6 +243,23 @@ pub struct ProviderMeta {
|
|||||||
/// 供应商单独的代理配置
|
/// 供应商单独的代理配置
|
||||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||||
pub proxy_config: Option<ProviderProxyConfig>,
|
pub proxy_config: Option<ProviderProxyConfig>,
|
||||||
|
/// 是否启用通用配置片段(用于跨供应商保持勾选状态)
|
||||||
|
#[serde(
|
||||||
|
rename = "commonConfigEnabled",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub common_config_enabled: Option<bool>,
|
||||||
|
/// 按应用记录通用配置启用状态(优先于 commonConfigEnabled)
|
||||||
|
#[serde(
|
||||||
|
rename = "commonConfigEnabledByApp",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub common_config_enabled_by_app: Option<CommonConfigEnabledByApp>,
|
||||||
|
/// 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
@@ -438,11 +471,18 @@ impl UniversalProvider {
|
|||||||
.and_then(|m| m.reasoning_effort.clone())
|
.and_then(|m| m.reasoning_effort.clone())
|
||||||
.unwrap_or_else(|| "high".to_string());
|
.unwrap_or_else(|| "high".to_string());
|
||||||
|
|
||||||
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API)
|
// Codex/OpenAI 的 base_url 既可能是纯 origin(需要补 /v1),也可能包含自定义前缀(不应强行补版本)
|
||||||
let codex_base_url = if self.base_url.ends_with("/v1") {
|
let base_trimmed = self.base_url.trim_end_matches('/');
|
||||||
self.base_url.clone()
|
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")
|
||||||
} else {
|
} else {
|
||||||
format!("{}/v1", self.base_url.trim_end_matches('/'))
|
base_trimmed.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成 Codex 的 config.toml 内容
|
// 生成 Codex 的 config.toml 内容
|
||||||
@@ -614,3 +654,310 @@ pub struct OpenCodeModelLimit {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub output: Option<u64>,
|
pub output: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod provider_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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ use super::{
|
|||||||
failover_switch::FailoverSwitchManager,
|
failover_switch::FailoverSwitchManager,
|
||||||
provider_router::ProviderRouter,
|
provider_router::ProviderRouter,
|
||||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
|
||||||
|
thinking_rectifier::{
|
||||||
|
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||||
|
},
|
||||||
types::{ProxyStatus, RectifierConfig},
|
types::{ProxyStatus, RectifierConfig},
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
@@ -157,6 +160,7 @@ impl RequestForwarder {
|
|||||||
|
|
||||||
// 整流器重试标记:确保整流最多触发一次
|
// 整流器重试标记:确保整流最多触发一次
|
||||||
let mut rectifier_retried = false;
|
let mut rectifier_retried = false;
|
||||||
|
let mut budget_rectifier_retried = false;
|
||||||
|
|
||||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||||
let bypass_circuit_breaker = providers.len() == 1;
|
let bypass_circuit_breaker = providers.len() == 1;
|
||||||
@@ -258,6 +262,7 @@ impl RequestForwarder {
|
|||||||
provider_type,
|
provider_type,
|
||||||
ProviderType::Claude | ProviderType::ClaudeAuth
|
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||||
);
|
);
|
||||||
|
let mut signature_rectifier_non_retryable_client_error = false;
|
||||||
|
|
||||||
if is_anthropic_provider {
|
if is_anthropic_provider {
|
||||||
let error_message = extract_error_message(&e);
|
let error_message = extract_error_message(&e);
|
||||||
@@ -293,12 +298,185 @@ impl RequestForwarder {
|
|||||||
// 首次触发:整流请求体
|
// 首次触发:整流请求体
|
||||||
let rectified = rectify_anthropic_request(&mut body);
|
let rectified = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
// 整流未生效:直接返回错误(不可重试客户端错误)
|
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
|
||||||
if !rectified.applied {
|
if !rectified.applied {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回"
|
||||||
|
);
|
||||||
|
signature_rectifier_non_retryable_client_error = true;
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||||
|
app_type_str,
|
||||||
|
rectified.removed_thinking_blocks,
|
||||||
|
rectified.removed_redacted_thinking_blocks,
|
||||||
|
rectified.removed_signature_fields
|
||||||
|
);
|
||||||
|
|
||||||
|
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||||
|
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||||
|
|
||||||
|
// 使用同一供应商重试(不计入熔断器)
|
||||||
|
match self
|
||||||
|
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||||
|
// 记录成功
|
||||||
|
let _ = self
|
||||||
|
.router
|
||||||
|
.record_result(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 更新当前应用类型使用的 provider
|
||||||
|
{
|
||||||
|
let mut current_providers =
|
||||||
|
self.current_providers.write().await;
|
||||||
|
current_providers.insert(
|
||||||
|
app_type_str.to_string(),
|
||||||
|
(provider.id.clone(), provider.name.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成功统计
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.success_requests += 1;
|
||||||
|
status.last_error = None;
|
||||||
|
let should_switch =
|
||||||
|
self.current_provider_id_at_start.as_str()
|
||||||
|
!= provider.id.as_str();
|
||||||
|
if should_switch {
|
||||||
|
status.failover_count += 1;
|
||||||
|
|
||||||
|
// 异步触发供应商切换,更新 UI/托盘
|
||||||
|
let fm = self.failover_manager.clone();
|
||||||
|
let ah = self.app_handle.clone();
|
||||||
|
let pid = provider.id.clone();
|
||||||
|
let pname = provider.name.clone();
|
||||||
|
let at = app_type_str.to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = fm
|
||||||
|
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests
|
||||||
|
as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(ForwardResult {
|
||||||
|
response,
|
||||||
|
provider: provider.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(retry_err) => {
|
||||||
|
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||||
|
log::warn!(
|
||||||
|
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||||
|
let is_provider_error = match &retry_err {
|
||||||
|
ProxyError::Timeout(_)
|
||||||
|
| ProxyError::ForwardFailed(_) => true,
|
||||||
|
ProxyError::UpstreamError { status, .. } => {
|
||||||
|
*status >= 500
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_provider_error {
|
||||||
|
// Provider 问题:记录失败到熔断器
|
||||||
|
let _ = self
|
||||||
|
.router
|
||||||
|
.record_result(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
false,
|
||||||
|
Some(retry_err.to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
// 客户端问题:仅释放 permit,不记录熔断器
|
||||||
|
self.router
|
||||||
|
.release_permit_neutral(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(retry_err.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: retry_err,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商)
|
||||||
|
if is_anthropic_provider {
|
||||||
|
let error_message = extract_error_message(&e);
|
||||||
|
if should_rectify_thinking_budget(
|
||||||
|
error_message.as_deref(),
|
||||||
|
&self.rectifier_config,
|
||||||
|
) {
|
||||||
|
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||||
|
if budget_rectifier_retried {
|
||||||
|
log::warn!(
|
||||||
|
"[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试"
|
||||||
|
);
|
||||||
|
self.router
|
||||||
|
.release_permit_neutral(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(e.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let budget_rectified = rectify_thinking_budget(&mut body);
|
||||||
|
if !budget_rectified.applied {
|
||||||
|
log::warn!(
|
||||||
|
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
|
||||||
);
|
);
|
||||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
|
||||||
self.router
|
self.router
|
||||||
.release_permit_neutral(
|
.release_permit_neutral(
|
||||||
&provider.id,
|
&provider.id,
|
||||||
@@ -321,15 +499,13 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}",
|
||||||
app_type_str,
|
app_type_str,
|
||||||
rectified.removed_thinking_blocks,
|
budget_rectified.before,
|
||||||
rectified.removed_redacted_thinking_blocks,
|
budget_rectified.after
|
||||||
rectified.removed_signature_fields
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
|
||||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
|
||||||
|
|
||||||
// 使用同一供应商重试(不计入熔断器)
|
// 使用同一供应商重试(不计入熔断器)
|
||||||
match self
|
match self
|
||||||
@@ -337,8 +513,7 @@ impl RequestForwarder {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||||
// 记录成功
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.router
|
.router
|
||||||
.record_result(
|
.record_result(
|
||||||
@@ -350,7 +525,6 @@ impl RequestForwarder {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// 更新当前应用类型使用的 provider
|
|
||||||
{
|
{
|
||||||
let mut current_providers =
|
let mut current_providers =
|
||||||
self.current_providers.write().await;
|
self.current_providers.write().await;
|
||||||
@@ -360,7 +534,6 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新成功统计
|
|
||||||
{
|
{
|
||||||
let mut status = self.status.write().await;
|
let mut status = self.status.write().await;
|
||||||
status.success_requests += 1;
|
status.success_requests += 1;
|
||||||
@@ -370,14 +543,11 @@ impl RequestForwarder {
|
|||||||
!= provider.id.as_str();
|
!= provider.id.as_str();
|
||||||
if should_switch {
|
if should_switch {
|
||||||
status.failover_count += 1;
|
status.failover_count += 1;
|
||||||
|
|
||||||
// 异步触发供应商切换,更新 UI/托盘
|
|
||||||
let fm = self.failover_manager.clone();
|
let fm = self.failover_manager.clone();
|
||||||
let ah = self.app_handle.clone();
|
let ah = self.app_handle.clone();
|
||||||
let pid = provider.id.clone();
|
let pid = provider.id.clone();
|
||||||
let pname = provider.name.clone();
|
let pname = provider.name.clone();
|
||||||
let at = app_type_str.to_string();
|
let at = app_type_str.to_string();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = fm
|
let _ = fm
|
||||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||||
@@ -397,12 +567,10 @@ impl RequestForwarder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(retry_err) => {
|
Err(retry_err) => {
|
||||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
|
||||||
let is_provider_error = match &retry_err {
|
let is_provider_error = match &retry_err {
|
||||||
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||||
true
|
true
|
||||||
@@ -412,7 +580,6 @@ impl RequestForwarder {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if is_provider_error {
|
if is_provider_error {
|
||||||
// Provider 问题:记录失败到熔断器
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.router
|
.router
|
||||||
.record_result(
|
.record_result(
|
||||||
@@ -424,7 +591,6 @@ impl RequestForwarder {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
// 客户端问题:仅释放 permit,不记录熔断器
|
|
||||||
self.router
|
self.router
|
||||||
.release_permit_neutral(
|
.release_permit_neutral(
|
||||||
&provider.id,
|
&provider.id,
|
||||||
@@ -451,6 +617,28 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if signature_rectifier_non_retryable_client_error {
|
||||||
|
self.router
|
||||||
|
.release_permit_neutral(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(e.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 失败:记录失败并更新熔断器
|
// 失败:记录失败并更新熔断器
|
||||||
let _ = self
|
let _ = self
|
||||||
.router
|
.router
|
||||||
@@ -575,6 +763,9 @@ impl RequestForwarder {
|
|||||||
let (mapped_body, _original_model, _mapped_model) =
|
let (mapped_body, _original_model, _mapped_model) =
|
||||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||||
|
|
||||||
|
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||||
|
let mapped_body = normalize_thinking_type(mapped_body);
|
||||||
|
|
||||||
// 转换请求体(如果需要)
|
// 转换请求体(如果需要)
|
||||||
let request_body = if needs_transform {
|
let request_body = if needs_transform {
|
||||||
adapter.transform_request(mapped_body, provider)?
|
adapter.transform_request(mapped_body, provider)?
|
||||||
@@ -666,7 +857,11 @@ impl RequestForwarder {
|
|||||||
|
|
||||||
// 输出请求信息日志
|
// 输出请求信息日志
|
||||||
let tag = adapter.name();
|
let tag = adapter.name();
|
||||||
log::debug!("[{tag}] >>> 请求 URL: {url}");
|
let request_model = filtered_body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("<none>");
|
||||||
|
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
|
||||||
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ use super::{
|
|||||||
};
|
};
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 健康检查和状态查询(简单端点)
|
// 健康检查和状态查询(简单端点)
|
||||||
@@ -145,6 +143,7 @@ async fn handle_claude_transform(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
"claude",
|
"claude",
|
||||||
&model,
|
&model,
|
||||||
|
&model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
first_token_ms,
|
first_token_ms,
|
||||||
@@ -215,6 +214,7 @@ async fn handle_claude_transform(
|
|||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
let latency_ms = ctx.latency_ms();
|
let latency_ms = ctx.latency_ms();
|
||||||
|
|
||||||
|
let request_model = ctx.request_model.clone();
|
||||||
tokio::spawn({
|
tokio::spawn({
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let provider_id = ctx.provider.id.clone();
|
let provider_id = ctx.provider.id.clone();
|
||||||
@@ -225,6 +225,7 @@ async fn handle_claude_transform(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
"claude",
|
"claude",
|
||||||
&model,
|
&model,
|
||||||
|
&request_model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
None,
|
None,
|
||||||
@@ -283,7 +284,7 @@ pub async fn handle_chat_completions(
|
|||||||
let result = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/chat/completions",
|
"/chat/completions",
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
ctx.get_providers(),
|
ctx.get_providers(),
|
||||||
@@ -324,7 +325,7 @@ pub async fn handle_responses(
|
|||||||
let result = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/responses",
|
"/responses",
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
ctx.get_providers(),
|
ctx.get_providers(),
|
||||||
@@ -441,6 +442,7 @@ async fn log_usage(
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
request_model: &str,
|
||||||
usage: TokenUsage,
|
usage: TokenUsage,
|
||||||
latency_ms: u64,
|
latency_ms: u64,
|
||||||
first_token_ms: Option<u64>,
|
first_token_ms: Option<u64>,
|
||||||
@@ -451,25 +453,12 @@ async fn log_usage(
|
|||||||
|
|
||||||
let logger = UsageLogger::new(&state.db);
|
let logger = UsageLogger::new(&state.db);
|
||||||
|
|
||||||
// 获取 provider 的 cost_multiplier
|
let (multiplier, pricing_model_source) =
|
||||||
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||||
Ok(Some(p)) => {
|
let pricing_model = if pricing_model_source == "request" {
|
||||||
if let Some(meta) = p.meta {
|
request_model
|
||||||
if let Some(cm) = meta.cost_multiplier {
|
} else {
|
||||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
model
|
||||||
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();
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
@@ -479,6 +468,8 @@ async fn log_usage(
|
|||||||
provider_id.to_string(),
|
provider_id.to_string(),
|
||||||
app_type.to_string(),
|
app_type.to_string(),
|
||||||
model.to_string(),
|
model.to_string(),
|
||||||
|
request_model.to_string(),
|
||||||
|
pricing_model.to_string(),
|
||||||
usage,
|
usage,
|
||||||
multiplier,
|
multiplier,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
|
|||||||
@@ -17,6 +17,33 @@ static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
|
|||||||
/// 当前代理 URL(用于日志和状态查询)
|
/// 当前代理 URL(用于日志和状态查询)
|
||||||
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
|
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 客户端
|
/// 初始化全局 HTTP 客户端
|
||||||
///
|
///
|
||||||
/// 应在应用启动时调用一次。
|
/// 应在应用启动时调用一次。
|
||||||
@@ -258,9 +285,17 @@ fn proxy_points_to_loopback(value: &str) -> bool {
|
|||||||
.unwrap_or(false)
|
.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 Ok(parsed) = url::Url::parse(value) {
|
||||||
if let Some(host) = parsed.host_str() {
|
if let Some(host) = parsed.host_str() {
|
||||||
return host_is_loopback(host);
|
// 只有当主机是 loopback 且端口是 CC Switch 的端口时才返回 true
|
||||||
|
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -268,7 +303,7 @@ fn proxy_points_to_loopback(value: &str) -> bool {
|
|||||||
let with_scheme = format!("http://{value}");
|
let with_scheme = format!("http://{value}");
|
||||||
if let Ok(parsed) = url::Url::parse(&with_scheme) {
|
if let Ok(parsed) = url::Url::parse(&with_scheme) {
|
||||||
if let Some(host) = parsed.host_str() {
|
if let Some(host) = parsed.host_str() {
|
||||||
return host_is_loopback(host);
|
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,16 +483,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_proxy_points_to_loopback() {
|
fn test_proxy_points_to_loopback() {
|
||||||
assert!(proxy_points_to_loopback("http://127.0.0.1:7890"));
|
// 设置 CC Switch 代理端口为 15721(默认值)
|
||||||
assert!(proxy_points_to_loopback("socks5://localhost:1080"));
|
set_proxy_port(15721);
|
||||||
assert!(proxy_points_to_loopback("127.0.0.1:7890"));
|
|
||||||
|
// 只有指向 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://192.168.1.10:7890"));
|
assert!(!proxy_points_to_loopback("http://192.168.1.10:7890"));
|
||||||
|
assert!(!proxy_points_to_loopback("http://192.168.1.10:15721"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_system_proxy_points_to_loopback() {
|
fn test_system_proxy_points_to_loopback() {
|
||||||
let _guard = env_lock().lock().unwrap();
|
let _guard = env_lock().lock().unwrap();
|
||||||
|
|
||||||
|
// 设置 CC Switch 代理端口
|
||||||
|
set_proxy_port(15721);
|
||||||
|
|
||||||
let keys = [
|
let keys = [
|
||||||
"HTTP_PROXY",
|
"HTTP_PROXY",
|
||||||
"http_proxy",
|
"http_proxy",
|
||||||
@@ -471,9 +520,15 @@ mod tests {
|
|||||||
std::env::remove_var(key);
|
std::env::remove_var(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
|
// 指向 CC Switch 端口的代理应该被跳过
|
||||||
|
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:15721");
|
||||||
assert!(system_proxy_points_to_loopback());
|
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");
|
std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890");
|
||||||
assert!(!system_proxy_points_to_loopback());
|
assert!(!system_proxy_points_to_loopback());
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod response_handler;
|
|||||||
pub mod response_processor;
|
pub mod response_processor;
|
||||||
pub(crate) mod server;
|
pub(crate) mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod thinking_budget_rectifier;
|
||||||
pub mod thinking_rectifier;
|
pub mod thinking_rectifier;
|
||||||
pub(crate) mod types;
|
pub(crate) mod types;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|||||||
@@ -97,11 +97,21 @@ impl ModelMapping {
|
|||||||
|
|
||||||
/// 检测请求是否启用了 thinking 模式
|
/// 检测请求是否启用了 thinking 模式
|
||||||
pub fn has_thinking_enabled(body: &Value) -> bool {
|
pub fn has_thinking_enabled(body: &Value) -> bool {
|
||||||
body.get("thinking")
|
match body
|
||||||
|
.get("thinking")
|
||||||
.and_then(|v| v.as_object())
|
.and_then(|v| v.as_object())
|
||||||
.and_then(|o| o.get("type"))
|
.and_then(|o| o.get("type"))
|
||||||
.and_then(|t| t.as_str())
|
.and_then(|t| t.as_str())
|
||||||
== Some("enabled")
|
{
|
||||||
|
Some("enabled") | Some("adaptive") => true,
|
||||||
|
Some("disabled") | None => false,
|
||||||
|
Some(other) => {
|
||||||
|
log::warn!(
|
||||||
|
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 对请求体应用模型映射
|
/// 对请求体应用模型映射
|
||||||
@@ -300,6 +310,30 @@ mod tests {
|
|||||||
assert!(mapped.is_none());
|
assert!(mapped.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thinking_adaptive() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"thinking": {"type": "adaptive"}
|
||||||
|
});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "reasoning-model");
|
||||||
|
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thinking_unknown_type() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"thinking": {"type": "some_future_type"}
|
||||||
|
});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "sonnet-mapped");
|
||||||
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_case_insensitive() {
|
fn test_case_insensitive() {
|
||||||
let provider = create_provider_with_mapping();
|
let provider = create_provider_with_mapping();
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
//! Claude (Anthropic) Provider Adapter
|
//! Claude (Anthropic) Provider Adapter
|
||||||
//!
|
//!
|
||||||
//! 支持透传模式和 OpenRouter 兼容模式
|
//! 支持透传模式和 OpenAI Chat Completions 格式转换模式
|
||||||
|
//!
|
||||||
|
//! ## API 格式
|
||||||
|
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||||
|
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||||
//!
|
//!
|
||||||
//! ## 认证模式
|
//! ## 认证模式
|
||||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
|
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
|
||||||
|
|
||||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -48,22 +52,52 @@ impl ClaudeAdapter {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检测 OpenRouter 是否启用兼容模式
|
/// 获取 API 格式
|
||||||
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
|
///
|
||||||
if !self.is_openrouter(provider) {
|
/// 从 provider.meta.api_format 读取格式设置:
|
||||||
return false;
|
/// - "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 raw = provider.settings_config.get("openrouter_compat_mode");
|
||||||
match raw {
|
let enabled = match raw {
|
||||||
Some(serde_json::Value::Bool(enabled)) => *enabled,
|
Some(serde_json::Value::Bool(v)) => *v,
|
||||||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||||
Some(serde_json::Value::String(value)) => {
|
Some(serde_json::Value::String(value)) => {
|
||||||
let normalized = value.trim().to_lowercase();
|
let normalized = value.trim().to_lowercase();
|
||||||
normalized == "true" || normalized == "1"
|
normalized == "true" || normalized == "1"
|
||||||
}
|
}
|
||||||
// OpenRouter now supports Claude Code compatible API, default to passthrough
|
|
||||||
_ => false,
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
"openai_chat"
|
||||||
|
} else {
|
||||||
|
"anthropic"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,15 +252,23 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||||
|
|
||||||
let base = format!(
|
let mut base = format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
base_url.trim_end_matches('/'),
|
base_url.trim_end_matches('/'),
|
||||||
endpoint.trim_start_matches('/')
|
endpoint.trim_start_matches('/')
|
||||||
);
|
);
|
||||||
|
|
||||||
// 为 /v1/messages 端点添加 ?beta=true 参数
|
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
|
||||||
|
while base.contains("/v1/v1") {
|
||||||
|
base = base.replace("/v1/v1", "/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为 Claude 相关端点添加 ?beta=true 参数
|
||||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||||
if endpoint.contains("/v1/messages") && !endpoint.contains("?") {
|
// 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
|
||||||
|
if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions"))
|
||||||
|
&& !endpoint.contains('?')
|
||||||
|
{
|
||||||
format!("{base}?beta=true")
|
format!("{base}?beta=true")
|
||||||
} else {
|
} else {
|
||||||
base
|
base
|
||||||
@@ -253,21 +295,19 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||||
// NOTE:
|
// 根据 api_format 配置决定是否需要格式转换
|
||||||
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
|
// - "anthropic" (默认): 直接透传,无需转换
|
||||||
// Anthropic ↔ OpenAI 的格式转换。
|
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
|
||||||
//
|
self.get_api_format(provider) == "openai_chat"
|
||||||
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
|
||||||
self.is_openrouter_compat_enabled(_provider)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_request(
|
fn transform_request(
|
||||||
&self,
|
&self,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
provider: &Provider,
|
_provider: &Provider,
|
||||||
) -> Result<serde_json::Value, ProxyError> {
|
) -> Result<serde_json::Value, ProxyError> {
|
||||||
super::transform::anthropic_to_openai(body, provider)
|
super::transform::anthropic_to_openai(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||||
@@ -278,6 +318,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::provider::ProviderMeta;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn create_provider(config: serde_json::Value) -> Provider {
|
fn create_provider(config: serde_json::Value) -> Provider {
|
||||||
@@ -297,6 +338,23 @@ 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]
|
#[test]
|
||||||
fn test_extract_base_url_from_env() {
|
fn test_extract_base_url_from_env() {
|
||||||
let adapter = ClaudeAdapter::new();
|
let adapter = ClaudeAdapter::new();
|
||||||
@@ -459,6 +517,7 @@ mod tests {
|
|||||||
fn test_needs_transform() {
|
fn test_needs_transform() {
|
||||||
let adapter = ClaudeAdapter::new();
|
let adapter = ClaudeAdapter::new();
|
||||||
|
|
||||||
|
// Default: no transform (anthropic format) - no meta
|
||||||
let anthropic_provider = create_provider(json!({
|
let anthropic_provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||||
@@ -466,29 +525,96 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||||
|
|
||||||
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
|
// Explicit anthropic format in meta: no transform
|
||||||
let openrouter_provider = create_provider(json!({
|
let explicit_anthropic = create_provider_with_meta(
|
||||||
"env": {
|
json!({
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
"env": {
|
||||||
}
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
}));
|
}
|
||||||
assert!(!adapter.needs_transform(&openrouter_provider));
|
}),
|
||||||
|
ProviderMeta {
|
||||||
|
api_format: Some("anthropic".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!adapter.needs_transform(&explicit_anthropic));
|
||||||
|
|
||||||
// OpenRouter provider with explicit compat mode enabled should transform
|
// Legacy settings_config.api_format: openai_chat should enable transform
|
||||||
let openrouter_enabled = create_provider(json!({
|
let legacy_settings_api_format = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
"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
|
"openrouter_compat_mode": true
|
||||||
}));
|
}));
|
||||||
assert!(adapter.needs_transform(&openrouter_enabled));
|
assert!(adapter.needs_transform(&legacy_openrouter_bool));
|
||||||
|
|
||||||
let openrouter_disabled = create_provider(json!({
|
let legacy_openrouter_num = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
},
|
},
|
||||||
"openrouter_compat_mode": false
|
"openrouter_compat_mode": 1
|
||||||
}));
|
}));
|
||||||
assert!(!adapter.needs_transform(&openrouter_disabled));
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,10 +141,33 @@ impl ProviderAdapter for CodexAdapter {
|
|||||||
let base_trimmed = base_url.trim_end_matches('/');
|
let base_trimmed = base_url.trim_end_matches('/');
|
||||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||||
|
|
||||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
// OpenAI/Codex 的 base_url 可能是:
|
||||||
|
// - 纯 origin: https://api.openai.com (需要自动补 /v1)
|
||||||
|
// - 已含 /v1: https://api.openai.com/v1 (直接拼接)
|
||||||
|
// - 自定义前缀: https://xxx/openai (不添加 /v1,直接拼接)
|
||||||
|
|
||||||
// 去除重复的 /v1/v1
|
// 检查 base_url 是否已经包含 /v1
|
||||||
if url.contains("/v1/v1") {
|
let already_has_v1 = base_trimmed.ends_with("/v1");
|
||||||
|
|
||||||
|
// 检查是否是纯 origin(没有路径部分)
|
||||||
|
let origin_only = match base_trimmed.split_once("://") {
|
||||||
|
Some((_scheme, rest)) => !rest.contains('/'),
|
||||||
|
None => !base_trimmed.contains('/'),
|
||||||
|
};
|
||||||
|
|
||||||
|
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") {
|
||||||
url = url.replace("/v1/v1", "/v1");
|
url = url.replace("/v1/v1", "/v1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +246,20 @@ mod tests {
|
|||||||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
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]
|
#[test]
|
||||||
fn test_build_url_dedup_v1() {
|
fn test_build_url_dedup_v1() {
|
||||||
let adapter = CodexAdapter::new();
|
let adapter = CodexAdapter::new();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! 用于 Anthropic Messages API 的请求/响应格式转换
|
//! 用于 Anthropic Messages API 的请求/响应格式转换
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
|
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
@@ -3,77 +3,16 @@
|
|||||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||||
//! 参考: anthropic-proxy-rs
|
//! 参考: anthropic-proxy-rs
|
||||||
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::proxy::error::ProxyError;
|
use crate::proxy::error::ProxyError;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
/// 从 Provider 配置中获取模型映射
|
|
||||||
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
|
|
||||||
let env = provider.settings_config.get("env");
|
|
||||||
let model_lower = model.to_lowercase();
|
|
||||||
|
|
||||||
// 检测 thinking 参数
|
|
||||||
let has_thinking = body
|
|
||||||
.get("thinking")
|
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.and_then(|o| o.get("type"))
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
== Some("enabled");
|
|
||||||
|
|
||||||
if let Some(env) = env {
|
|
||||||
// 如果启用 thinking,优先使用推理模型
|
|
||||||
if has_thinking {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_REASONING_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
log::debug!("[Transform] 使用推理模型: {m}");
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据模型类型选择配置模型
|
|
||||||
if model_lower.contains("haiku") {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if model_lower.contains("opus") {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if model_lower.contains("sonnet") {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 默认使用 ANTHROPIC_MODEL
|
|
||||||
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
model.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Anthropic 请求 → OpenAI 请求
|
/// Anthropic 请求 → OpenAI 请求
|
||||||
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
|
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||||
let mut result = json!({});
|
let mut result = json!({});
|
||||||
|
|
||||||
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
|
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||||
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
||||||
let mapped_model = get_model_from_provider(model, provider, &body);
|
result["model"] = json!(model);
|
||||||
result["model"] = json!(mapped_model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
@@ -381,45 +320,16 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn create_provider(env_config: Value) -> Provider {
|
|
||||||
Provider {
|
|
||||||
id: "test".to_string(),
|
|
||||||
name: "Test Provider".to_string(),
|
|
||||||
settings_config: json!({"env": env_config}),
|
|
||||||
website_url: None,
|
|
||||||
category: None,
|
|
||||||
created_at: None,
|
|
||||||
sort_index: None,
|
|
||||||
notes: None,
|
|
||||||
meta: None,
|
|
||||||
icon: None,
|
|
||||||
icon_color: None,
|
|
||||||
in_failover_queue: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_openrouter_provider() -> Provider {
|
|
||||||
create_provider(json!({
|
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
|
||||||
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
|
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
|
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
|
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_simple() {
|
fn test_anthropic_to_openai_simple() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
|
assert_eq!(result["model"], "claude-3-opus");
|
||||||
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
|
|
||||||
assert_eq!(result["max_tokens"], 1024);
|
assert_eq!(result["max_tokens"], 1024);
|
||||||
assert_eq!(result["messages"][0]["role"], "user");
|
assert_eq!(result["messages"][0]["role"], "user");
|
||||||
assert_eq!(result["messages"][0]["content"], "Hello");
|
assert_eq!(result["messages"][0]["content"], "Hello");
|
||||||
@@ -427,7 +337,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_with_system() {
|
fn test_anthropic_to_openai_with_system() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-sonnet",
|
"model": "claude-3-sonnet",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
@@ -435,7 +344,7 @@ mod tests {
|
|||||||
"messages": [{"role": "user", "content": "Hello"}]
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
assert_eq!(result["messages"][0]["role"], "system");
|
assert_eq!(result["messages"][0]["role"], "system");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result["messages"][0]["content"],
|
result["messages"][0]["content"],
|
||||||
@@ -446,7 +355,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_with_tools() {
|
fn test_anthropic_to_openai_with_tools() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
@@ -458,14 +366,13 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
assert_eq!(result["tools"][0]["type"], "function");
|
assert_eq!(result["tools"][0]["type"], "function");
|
||||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_tool_use() {
|
fn test_anthropic_to_openai_tool_use() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
@@ -478,7 +385,7 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
let msg = &result["messages"][0];
|
let msg = &result["messages"][0];
|
||||||
assert_eq!(msg["role"], "assistant");
|
assert_eq!(msg["role"], "assistant");
|
||||||
assert!(msg.get("tool_calls").is_some());
|
assert!(msg.get("tool_calls").is_some());
|
||||||
@@ -487,7 +394,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_tool_result() {
|
fn test_anthropic_to_openai_tool_result() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
@@ -499,7 +405,7 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
let msg = &result["messages"][0];
|
let msg = &result["messages"][0];
|
||||||
assert_eq!(msg["role"], "tool");
|
assert_eq!(msg["role"], "tool");
|
||||||
assert_eq!(msg["tool_call_id"], "call_123");
|
assert_eq!(msg["tool_call_id"], "call_123");
|
||||||
@@ -563,78 +469,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_model_mapping_from_provider() {
|
fn test_model_passthrough() {
|
||||||
let provider = create_openrouter_provider();
|
// 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理
|
||||||
let body = json!({"model": "test"});
|
|
||||||
|
|
||||||
// sonnet 模型
|
|
||||||
assert_eq!(
|
|
||||||
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
|
|
||||||
"anthropic/claude-sonnet-4.5"
|
|
||||||
);
|
|
||||||
|
|
||||||
// haiku 模型
|
|
||||||
assert_eq!(
|
|
||||||
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
|
|
||||||
"anthropic/claude-haiku-4.5"
|
|
||||||
);
|
|
||||||
|
|
||||||
// opus 模型
|
|
||||||
assert_eq!(
|
|
||||||
get_model_from_provider("claude-opus-4-5", &provider, &body),
|
|
||||||
"anthropic/claude-opus-4.5"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_anthropic_to_openai_model_mapping() {
|
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-sonnet-4-5-20250929",
|
"model": "gpt-4o",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
assert_eq!(result["model"], "gpt-4o");
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_thinking_parameter_detection() {
|
|
||||||
let mut provider = create_openrouter_provider();
|
|
||||||
// 添加推理模型配置
|
|
||||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
|
||||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"thinking": {"type": "enabled"},
|
|
||||||
"messages": [{"role": "user", "content": "Solve this problem"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
// 应该使用推理模型
|
|
||||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_thinking_parameter_disabled() {
|
|
||||||
let mut provider = create_openrouter_provider();
|
|
||||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
|
||||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"thinking": {"type": "disabled"},
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
// 应该使用普通模型
|
|
||||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ use axum::response::{IntoResponse, Response};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::stream::{Stream, StreamExt};
|
use futures::stream::{Stream, StreamExt};
|
||||||
use reqwest::header::HeaderMap;
|
use reqwest::header::HeaderMap;
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::{
|
use std::{
|
||||||
str::FromStr,
|
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
@@ -128,7 +126,15 @@ pub async fn handle_non_streaming(
|
|||||||
ctx.request_model.clone()
|
ctx.request_model.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
|
spawn_log_usage(
|
||||||
|
state,
|
||||||
|
ctx,
|
||||||
|
usage,
|
||||||
|
&model,
|
||||||
|
&ctx.request_model,
|
||||||
|
status.as_u16(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
let model = json_value
|
let model = json_value
|
||||||
.get("model")
|
.get("model")
|
||||||
@@ -140,6 +146,7 @@ pub async fn handle_non_streaming(
|
|||||||
ctx,
|
ctx,
|
||||||
TokenUsage::default(),
|
TokenUsage::default(),
|
||||||
&model,
|
&model,
|
||||||
|
&ctx.request_model,
|
||||||
status.as_u16(),
|
status.as_u16(),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -159,6 +166,7 @@ pub async fn handle_non_streaming(
|
|||||||
ctx,
|
ctx,
|
||||||
TokenUsage::default(),
|
TokenUsage::default(),
|
||||||
&ctx.request_model,
|
&ctx.request_model,
|
||||||
|
&ctx.request_model,
|
||||||
status.as_u16(),
|
status.as_u16(),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -293,6 +301,7 @@ fn create_usage_collector(
|
|||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let provider_id = provider_id.clone();
|
let provider_id = provider_id.clone();
|
||||||
let session_id = session_id.clone();
|
let session_id = session_id.clone();
|
||||||
|
let request_model = request_model.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
log_usage_internal(
|
log_usage_internal(
|
||||||
@@ -300,6 +309,7 @@ fn create_usage_collector(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
app_type_str,
|
app_type_str,
|
||||||
&model,
|
&model,
|
||||||
|
&request_model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
first_token_ms,
|
first_token_ms,
|
||||||
@@ -315,6 +325,7 @@ fn create_usage_collector(
|
|||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let provider_id = provider_id.clone();
|
let provider_id = provider_id.clone();
|
||||||
let session_id = session_id.clone();
|
let session_id = session_id.clone();
|
||||||
|
let request_model = request_model.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
log_usage_internal(
|
log_usage_internal(
|
||||||
@@ -322,6 +333,7 @@ fn create_usage_collector(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
app_type_str,
|
app_type_str,
|
||||||
&model,
|
&model,
|
||||||
|
&request_model,
|
||||||
TokenUsage::default(),
|
TokenUsage::default(),
|
||||||
latency_ms,
|
latency_ms,
|
||||||
first_token_ms,
|
first_token_ms,
|
||||||
@@ -342,6 +354,7 @@ fn spawn_log_usage(
|
|||||||
ctx: &RequestContext,
|
ctx: &RequestContext,
|
||||||
usage: TokenUsage,
|
usage: TokenUsage,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
request_model: &str,
|
||||||
status_code: u16,
|
status_code: u16,
|
||||||
is_streaming: bool,
|
is_streaming: bool,
|
||||||
) {
|
) {
|
||||||
@@ -349,6 +362,7 @@ fn spawn_log_usage(
|
|||||||
let provider_id = ctx.provider.id.clone();
|
let provider_id = ctx.provider.id.clone();
|
||||||
let app_type_str = ctx.app_type_str.to_string();
|
let app_type_str = ctx.app_type_str.to_string();
|
||||||
let model = model.to_string();
|
let model = model.to_string();
|
||||||
|
let request_model = request_model.to_string();
|
||||||
let latency_ms = ctx.latency_ms();
|
let latency_ms = ctx.latency_ms();
|
||||||
let session_id = ctx.session_id.clone();
|
let session_id = ctx.session_id.clone();
|
||||||
|
|
||||||
@@ -358,6 +372,7 @@ fn spawn_log_usage(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
&app_type_str,
|
&app_type_str,
|
||||||
&model,
|
&model,
|
||||||
|
&request_model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
None,
|
None,
|
||||||
@@ -376,6 +391,7 @@ async fn log_usage_internal(
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
request_model: &str,
|
||||||
usage: TokenUsage,
|
usage: TokenUsage,
|
||||||
latency_ms: u64,
|
latency_ms: u64,
|
||||||
first_token_ms: Option<u64>,
|
first_token_ms: Option<u64>,
|
||||||
@@ -386,26 +402,12 @@ async fn log_usage_internal(
|
|||||||
use super::usage::logger::UsageLogger;
|
use super::usage::logger::UsageLogger;
|
||||||
|
|
||||||
let logger = UsageLogger::new(&state.db);
|
let logger = UsageLogger::new(&state.db);
|
||||||
|
let (multiplier, pricing_model_source) =
|
||||||
// 获取 provider 的 cost_multiplier
|
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||||
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
let pricing_model = if pricing_model_source == "request" {
|
||||||
Ok(Some(p)) => {
|
request_model
|
||||||
if let Some(meta) = p.meta {
|
} else {
|
||||||
if let Some(cm) = meta.cost_multiplier {
|
model
|
||||||
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();
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
@@ -424,6 +426,8 @@ async fn log_usage_internal(
|
|||||||
provider_id.to_string(),
|
provider_id.to_string(),
|
||||||
app_type.to_string(),
|
app_type.to_string(),
|
||||||
model.to_string(),
|
model.to_string(),
|
||||||
|
request_model.to_string(),
|
||||||
|
pricing_model.to_string(),
|
||||||
usage,
|
usage,
|
||||||
multiplier,
|
multiplier,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
@@ -556,3 +560,185 @@ fn format_headers(headers: &HeaderMap) -> String {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ impl ProxyServer {
|
|||||||
|
|
||||||
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
|
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);
|
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||||
|
|
||||||
|
|||||||