mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b16650036 | |||
| a052af0060 | |||
| 92785a8078 | |||
| 1d97570a94 | |||
| d98183f3da | |||
| 68a0c304d8 | |||
| 60007ee4e8 | |||
| 7bd29d721e | |||
| c153e7104e | |||
| e65360e68a | |||
| f0e8ba1d8f | |||
| 58153333ce | |||
| d098ecad64 | |||
| 809a1fcf84 | |||
| e5fea048a1 | |||
| b8bd1d30d9 | |||
| 4abf259a6d | |||
| faa82a5b86 | |||
| b5b45c2703 | |||
| bd8a323600 | |||
| 151e43a808 | |||
| 9d2bf08fe0 | |||
| 05c21e016f | |||
| 57713dd336 | |||
| 1ed122a8bd | |||
| 78e341ccb9 | |||
| 162800e18e | |||
| 065d5db843 | |||
| 70a18c1141 | |||
| 964767ebaf | |||
| 0c25687e09 | |||
| 81b975c47c | |||
| e44423c307 | |||
| 08d9bb4cab | |||
| 987fc46e06 | |||
| e3d335be2d | |||
| 0dd823ae3a | |||
| 164635f638 | |||
| fcb5163710 | |||
| 3095bf8e5c | |||
| a48502235c | |||
| c74f801d66 | |||
| 785e1b5add | |||
| c00f431d67 | |||
| 29a0643d74 | |||
| 5e92111771 | |||
| beebd9847f | |||
| d99a3c2fee | |||
| a0ca8c2517 | |||
| 3434dcb87c | |||
| 1c6689a0bc | |||
| 9404341f14 | |||
| 53dd0a90f3 | |||
| 779fefd86d | |||
| 096c1d57c4 | |||
| adb868d0cf | |||
| a6ad896db0 | |||
| d6cf4390ac | |||
| 0ef670325b | |||
| 07fc6b175e | |||
| 79d3ecc1b8 | |||
| 9c249d9486 | |||
| 15f60e8ce2 | |||
| 3d733a3b80 | |||
| cfb113ac8d | |||
| 62a4ab2ad3 | |||
| aa0f191420 | |||
| b8305f281b | |||
| b3af191fab | |||
| e6b6bc18a9 | |||
| b1f7ff4768 | |||
| 09d8c27972 | |||
| 9736861ace | |||
| fef750bb4c | |||
| 3677bb61f5 | |||
| 2d17bde790 | |||
| dc865fbbbf | |||
| a187380d6f | |||
| b993b1f664 | |||
| 00168877d9 | |||
| 4496110dd8 | |||
| c9e85e8cac | |||
| eab1d08527 | |||
| 30009ad5f1 | |||
| e7badb1a24 | |||
| 7bb458eecb | |||
| 76897e2b97 | |||
| fb9e7dee50 | |||
| e1d4dd7f55 | |||
| c847fff768 | |||
| 1eb0a0d7ac | |||
| 3bd3845ec0 | |||
| b0d0a2c466 | |||
| 73013c10af | |||
| 1a0872c153 | |||
| 255a7f570a | |||
| fb44fb136f | |||
| 58d3bb89d2 | |||
| 403227c901 | |||
| 5bcf5bf382 | |||
| 6ce6f16a99 | |||
| ad6f5b388b | |||
| 2844f7c557 | |||
| 882c73234f | |||
| b70de25de4 | |||
| 88dbeb5335 | |||
| 966d7b5782 | |||
| 42a92c712a | |||
| 2f0998c6c8 | |||
| 938e2eb563 | |||
| 2cc36b3950 | |||
| e06c6176d9 | |||
| 9b4485e111 | |||
| f349d85e85 | |||
| 58a13cc69a | |||
| d765364a18 | |||
| 5c6956b6e2 | |||
| cb1b45ae4e | |||
| e4df1a32a5 | |||
| 2494eaaa32 | |||
| 45b9cf1df0 | |||
| de3a22535d | |||
| 36d6d48002 | |||
| 864884926a | |||
| 093ff0ba29 | |||
| 21754a7349 | |||
| 7997b2c7b3 | |||
| b8538b211d | |||
| 7ea2c3452b | |||
| a30d72bb68 | |||
| 58ecc44ee6 | |||
| 5658d93924 | |||
| e4d24f2df9 | |||
| 07d022ba9f |
@@ -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 ' }'
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+144
@@ -7,6 +7,150 @@ 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
|
||||||
|
|
||||||
|
### Patch Release
|
||||||
|
|
||||||
|
This maintenance release adds skill sync options and includes important bug fixes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Skills**: Add skill sync method setting with symlink/copy options
|
||||||
|
- **Partners**: Add RightCode as official partner
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Prompts**: Clear prompt file when all prompts are disabled
|
||||||
|
- **OpenCode**: Preserve extra model fields during serialization
|
||||||
|
- **Provider Form**: Backfill model fields when editing Claude provider
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.10.1] - 2026-01-23
|
||||||
|
|
||||||
|
### Patch Release
|
||||||
|
|
||||||
|
This maintenance release includes important bug fixes for Windows platform, UI improvements, and code quality enhancements.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Provider Icons**: Updated RightCode provider icon with improved visual design
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Proxy Rectifier**: Changed rectifier default state to disabled for better stability
|
||||||
|
- **Window Settings**: Reordered window settings and updated default values for improved UX
|
||||||
|
- **UI Layout**: Increased app icon collapse threshold from 3 to 4 icons
|
||||||
|
- **Code Quality**: Simplified `RectifierConfig` implementation using `#[derive(Default)]`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Windows Platform**:
|
||||||
|
- Fixed terminal window closing immediately after execution on Windows
|
||||||
|
- Corrected OpenCode config path resolution on Windows
|
||||||
|
- **UI Improvements**:
|
||||||
|
- Fixed ProviderIcon color validation to prevent black icons from appearing
|
||||||
|
- Unified layout padding across all panels for consistent spacing
|
||||||
|
- Fixed panel content alignment with header constraints
|
||||||
|
- **Code Quality**: Resolved Rust Clippy warnings and applied consistent formatting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.10.0] - 2026-01-21
|
||||||
|
|
||||||
|
### Feature Release
|
||||||
|
|
||||||
|
This release introduces OpenCode support and brings improvements across proxy, usage tracking, and overall UX.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **OpenCode Support** - Manage OpenCode providers, MCP servers, and Skills, with first-launch import and full internationalization (#695)
|
||||||
|
- **Global Proxy** - Add global proxy settings for outbound network requests (#596)
|
||||||
|
- **Claude Rectifier** - Add thinking signature rectifier for Claude API (#595)
|
||||||
|
- **Health Check Enhancements** - Configurable prompt and CLI-compatible requests for stream health check (#623)
|
||||||
|
- **Per-Provider Config** - Support provider-specific configuration and persistence (#663)
|
||||||
|
- **App Visibility Controls** - Show/hide apps and keep tray menu in sync (Gemini hidden by default)
|
||||||
|
- **Takeover Compact Mode** - Use a compact AppSwitcher layout when showing 3+ visible apps
|
||||||
|
- **Keyboard Shortcut** - Press `ESC` to quickly go back/close panels (#670)
|
||||||
|
- **Terminal Improvements** - Provider-specific terminal button, `fnm` path support, and safer cross-platform launching (#564)
|
||||||
|
- **WSL Tool Detection** - Detect tool versions in WSL with additional security hardening (#627)
|
||||||
|
- **Skills Presets** - Add `baoyu-skills` preset repo and auto-supplement missing default repos
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Proxy Logging** - Simplify proxy log output (#585)
|
||||||
|
- **Pricing Editor UX** - Unify pricing edit modal with `FullScreenPanel`
|
||||||
|
- **Advanced Settings Layout** - Move rectifier section below failover for better flow
|
||||||
|
- **OpenRouter Compat Mode** - Disable OpenRouter compatibility mode by default and hide UI toggle
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Auto Failover** - Switch to P1 immediately when enabling auto failover
|
||||||
|
- **Provider Edit Dialog** - Fix stale data when reopening provider editor after save (#654)
|
||||||
|
- **Deeplink** - Support multiple endpoints and prioritize `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL` (#597)
|
||||||
|
- **MCP (WSL)** - Skip `cmd /c` wrapper for WSL target paths (#592)
|
||||||
|
- **Usage Templates** - Add variable hints and validation fixes; prevent config leaking between providers (#628)
|
||||||
|
- **Gemini Timeout Format** - Convert timeout params to Gemini CLI format (#580)
|
||||||
|
- **UI** - Fix Select dropdown rendering in `FullScreenPanel`; auto-apply default icon color when unset
|
||||||
|
- **Usage UI** - Auto-adapt usage block offset based on action buttons width (#613)
|
||||||
|
- **Provider Endpoint** - Persist endpoint auto-select state (#611)
|
||||||
|
- **Provider Form** - Reset baseUrl and apiKey states when switching presets
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [3.9.1] - 2026-01-09
|
## [3.9.1] - 2026-01-09
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -33,8 +33,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 +43,16 @@ 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://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
|
||||||
@@ -52,7 +63,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
### Current Version: v3.10.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||||
|
|
||||||
**v3.8.0 Major Update (2025-11-28)**
|
**v3.8.0 Major Update (2025-11-28)**
|
||||||
|
|
||||||
@@ -181,7 +192,7 @@ Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) pa
|
|||||||
|
|
||||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
||||||
|
|
||||||
### ArchLinux 用户
|
### Arch Linux Users
|
||||||
|
|
||||||
**Install via paru (Recommended)**
|
**Install via paru (Recommended)**
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -33,8 +33,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 +43,16 @@
|
|||||||
<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://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>
|
||||||
|
|
||||||
## スクリーンショット
|
## スクリーンショット
|
||||||
@@ -52,7 +63,7 @@
|
|||||||
|
|
||||||
## 特長
|
## 特長
|
||||||
|
|
||||||
### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
### 現在のバージョン:v3.10.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||||
|
|
||||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -31,6 +31,18 @@
|
|||||||
<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 +50,8 @@
|
|||||||
</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://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" 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>感谢 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>
|
||||||
@@ -52,7 +64,7 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
### 当前版本:v3.10.2 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
||||||
|
|
||||||
**v3.8.0 重大更新(2025-11-28)**
|
**v3.8.0 重大更新(2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 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,485 @@
|
|||||||
|
# OpenCode 第四应用支持实现计划
|
||||||
|
|
||||||
|
> **范围说明**:本计划暂不包含统一供应商(UniversalProvider)对 OpenCode 的支持,以降低初期实现复杂度。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
为 CC Switch 添加 OpenCode 支持,这是第四个受管理的 CLI 应用。OpenCode 的核心差异在于采用**累加式**供应商管理(多供应商共存,应用内热切换),而非现有三应用的**替换式**管理。
|
||||||
|
|
||||||
|
## 关键设计决策
|
||||||
|
|
||||||
|
| 特性 | Claude/Codex/Gemini | OpenCode |
|
||||||
|
|------|---------------------|----------|
|
||||||
|
| 供应商模式 | 替换式(单一活跃) | 累加式(多供应商共存) |
|
||||||
|
| UI 按钮 | 启用/切换 | 添加/删除 |
|
||||||
|
| is_current | 需要 | 不需要 |
|
||||||
|
| 代理/故障转移 | 支持 | 不支持 |
|
||||||
|
| API 格式字段 | 无 | 需要(npm 包名) |
|
||||||
|
| 配置文件 | 各自独立 | `~/.config/opencode/opencode.json` |
|
||||||
|
|
||||||
|
## 配置文件格式
|
||||||
|
|
||||||
|
### 供应商配置
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"provider-id": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "Provider Name",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "https://api.example.com/v1",
|
||||||
|
"apiKey": "{env:API_KEY}"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"model-id": { "name": "Model Name" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP 配置
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"remote-server": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://example.com/mcp",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"local-server": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["npx", "-y", "my-mcp-command"],
|
||||||
|
"enabled": true,
|
||||||
|
"environment": { "KEY": "value" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
### Phase 1: 后端数据结构扩展
|
||||||
|
|
||||||
|
#### 1.1 AppType 枚举扩展
|
||||||
|
**文件**: `src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum AppType {
|
||||||
|
Claude,
|
||||||
|
Codex,
|
||||||
|
Gemini,
|
||||||
|
OpenCode, // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 McpApps / SkillApps 扩展
|
||||||
|
**文件**: `src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct McpApps {
|
||||||
|
pub claude: bool,
|
||||||
|
pub codex: bool,
|
||||||
|
pub gemini: bool,
|
||||||
|
pub opencode: bool, // 新增
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SkillApps {
|
||||||
|
pub claude: bool,
|
||||||
|
pub codex: bool,
|
||||||
|
pub gemini: bool,
|
||||||
|
pub opencode: bool, // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 数据库 Schema 迁移
|
||||||
|
**文件**: `src-tauri/src/database/schema.rs`
|
||||||
|
|
||||||
|
- `SCHEMA_VERSION` 递增
|
||||||
|
- 添加迁移:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE mcp_servers ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE skills ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: OpenCode 供应商数据结构
|
||||||
|
|
||||||
|
#### 2.1 OpenCode 专属配置结构
|
||||||
|
**文件**: `src-tauri/src/provider.rs`(或新建 `opencode_provider.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// OpenCode 供应商的 settings_config 结构
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeProviderConfig {
|
||||||
|
/// AI SDK 包名,如 "@ai-sdk/openai-compatible"
|
||||||
|
pub npm: String,
|
||||||
|
/// 供应商选项
|
||||||
|
pub options: OpenCodeProviderOptions,
|
||||||
|
/// 模型定义
|
||||||
|
pub models: HashMap<String, OpenCodeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeProviderOptions {
|
||||||
|
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub headers: Option<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeModel {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<OpenCodeModelLimit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeModelLimit {
|
||||||
|
pub context: Option<u64>,
|
||||||
|
pub output: Option<u64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: OpenCode Live 配置读写
|
||||||
|
|
||||||
|
#### 3.1 新建 OpenCode 配置模块
|
||||||
|
**文件**: `src-tauri/src/opencode_config.rs`
|
||||||
|
|
||||||
|
核心功能:
|
||||||
|
- `get_opencode_config_path()` → `~/.config/opencode/opencode.json`
|
||||||
|
- `read_opencode_config()` → 读取整个配置文件
|
||||||
|
- `write_opencode_config()` → 原子写入配置文件
|
||||||
|
- `get_providers()` → 获取 `provider` 对象
|
||||||
|
- `set_provider(id, config)` → 添加/更新供应商
|
||||||
|
- `remove_provider(id)` → 删除供应商
|
||||||
|
- `get_mcp_servers()` → 获取 `mcp` 对象
|
||||||
|
- `set_mcp_server(id, config)` → 添加/更新 MCP 服务器
|
||||||
|
- `remove_mcp_server(id)` → 删除 MCP 服务器
|
||||||
|
|
||||||
|
### Phase 4: MCP 同步模块
|
||||||
|
|
||||||
|
#### 4.1 新建 OpenCode MCP 同步
|
||||||
|
**文件**: `src-tauri/src/mcp/opencode.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// 同步所有 enabled_opencode=true 的服务器到 OpenCode 配置
|
||||||
|
pub fn sync_enabled_to_opencode(config: &MultiAppConfig) -> Result<(), AppError>
|
||||||
|
|
||||||
|
/// 同步单个服务器
|
||||||
|
pub fn sync_single_server_to_opencode(
|
||||||
|
config: &MultiAppConfig,
|
||||||
|
id: &str,
|
||||||
|
server_spec: &Value
|
||||||
|
) -> Result<(), AppError>
|
||||||
|
|
||||||
|
/// 从 OpenCode 配置移除服务器
|
||||||
|
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError>
|
||||||
|
|
||||||
|
/// 从 OpenCode 配置导入服务器
|
||||||
|
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||||
|
```
|
||||||
|
|
||||||
|
**格式转换**:
|
||||||
|
| CC Switch 统一格式 | OpenCode 格式 |
|
||||||
|
|-------------------|---------------|
|
||||||
|
| `type: "stdio"` | `type: "local"` |
|
||||||
|
| `command` + `args` | `command: [cmd, ...args]` |
|
||||||
|
| `env` | `environment` |
|
||||||
|
| `type: "sse"/"http"` | `type: "remote"` |
|
||||||
|
| `url` | `url` |
|
||||||
|
|
||||||
|
### Phase 5: 供应商服务层
|
||||||
|
|
||||||
|
#### 5.1 OpenCode 供应商服务
|
||||||
|
**文件**: `src-tauri/src/services/provider/opencode.rs`
|
||||||
|
|
||||||
|
核心方法:
|
||||||
|
```rust
|
||||||
|
/// 获取所有 OpenCode 供应商
|
||||||
|
pub fn list(state: &AppState) -> Result<IndexMap<String, Provider>, AppError>
|
||||||
|
|
||||||
|
/// 添加供应商(同时写入 live 配置)
|
||||||
|
pub fn add(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||||
|
|
||||||
|
/// 更新供应商
|
||||||
|
pub fn update(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||||
|
|
||||||
|
/// 删除供应商(同时从 live 配置移除)
|
||||||
|
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError>
|
||||||
|
|
||||||
|
/// 从 live 配置导入供应商到数据库
|
||||||
|
pub fn import_from_live(state: &AppState) -> Result<usize, AppError>
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键差异**:
|
||||||
|
- 不需要 `switch()` 方法
|
||||||
|
- 不需要 `is_current` 管理
|
||||||
|
- `add()` 自动写入 live
|
||||||
|
- `delete()` 自动从 live 移除
|
||||||
|
|
||||||
|
### Phase 6: Tauri 命令扩展
|
||||||
|
|
||||||
|
#### 6.1 更新现有命令
|
||||||
|
**文件**: `src-tauri/src/commands/providers.rs`
|
||||||
|
|
||||||
|
- 所有命令支持 `app_type = "opencode"`
|
||||||
|
- OpenCode 特定逻辑分支
|
||||||
|
|
||||||
|
#### 6.2 新增 OpenCode 专属命令(如需要)
|
||||||
|
```rust
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn opencode_sync_all_providers(state: State<'_, AppState>) -> Result<(), AppError>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 7: 前端类型定义
|
||||||
|
|
||||||
|
#### 7.1 TypeScript 类型扩展
|
||||||
|
**文件**: `src/types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AppId 扩展
|
||||||
|
type AppId = "claude" | "codex" | "gemini" | "opencode";
|
||||||
|
|
||||||
|
// OpenCode 专属配置
|
||||||
|
interface OpenCodeProviderConfig {
|
||||||
|
npm: string; // AI SDK 包名
|
||||||
|
options: {
|
||||||
|
baseURL?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
models: Record<string, OpenCodeModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenCodeModel {
|
||||||
|
name: string;
|
||||||
|
limit?: {
|
||||||
|
context?: number;
|
||||||
|
output?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7.2 MCP 应用状态扩展
|
||||||
|
**文件**: `src/types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface McpApps {
|
||||||
|
claude: boolean;
|
||||||
|
codex: boolean;
|
||||||
|
gemini: boolean;
|
||||||
|
opencode: boolean; // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 8: 前端预设配置
|
||||||
|
|
||||||
|
#### 8.1 新建 OpenCode 供应商预设
|
||||||
|
**文件**: `src/config/opencodeProviderPresets.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const opencodeProviderPresets: ProviderPreset[] = [
|
||||||
|
{
|
||||||
|
name: "OpenAI",
|
||||||
|
npmPackage: "@ai-sdk/openai",
|
||||||
|
settingsConfig: {
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
options: { apiKey: "{env:OPENAI_API_KEY}" },
|
||||||
|
models: {
|
||||||
|
"gpt-4o": { name: "GPT-4o" },
|
||||||
|
"gpt-4o-mini": { name: "GPT-4o Mini" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
theme: { icon: "openai", iconColor: "#00A67E" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Anthropic",
|
||||||
|
npmPackage: "@ai-sdk/anthropic",
|
||||||
|
settingsConfig: {
|
||||||
|
npm: "@ai-sdk/anthropic",
|
||||||
|
options: { apiKey: "{env:ANTHROPIC_API_KEY}" },
|
||||||
|
models: {
|
||||||
|
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenAI Compatible",
|
||||||
|
npmPackage: "@ai-sdk/openai-compatible",
|
||||||
|
settingsConfig: {
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
options: {
|
||||||
|
baseURL: "",
|
||||||
|
apiKey: "{env:API_KEY}",
|
||||||
|
},
|
||||||
|
models: {},
|
||||||
|
},
|
||||||
|
isCustomTemplate: true,
|
||||||
|
},
|
||||||
|
// ... 更多预设
|
||||||
|
];
|
||||||
|
|
||||||
|
// npm 包选项
|
||||||
|
export const opencodeNpmPackages = [
|
||||||
|
{ value: "@ai-sdk/openai", label: "OpenAI" },
|
||||||
|
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
|
||||||
|
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
|
||||||
|
{ value: "@ai-sdk/google", label: "Google" },
|
||||||
|
{ value: "@ai-sdk/azure", label: "Azure OpenAI" },
|
||||||
|
{ value: "@ai-sdk/amazon-bedrock", label: "Amazon Bedrock" },
|
||||||
|
// ... 更多选项
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 9: 前端 UI 组件
|
||||||
|
|
||||||
|
#### 9.1 OpenCode 供应商表单
|
||||||
|
**文件**: `src/components/providers/forms/OpenCodeFormFields.tsx`
|
||||||
|
|
||||||
|
新增字段:
|
||||||
|
- npm 包选择器(下拉框 + 自定义输入)
|
||||||
|
- options 编辑器(baseURL, apiKey, headers)
|
||||||
|
- models 编辑器(动态添加/删除模型)
|
||||||
|
|
||||||
|
#### 9.2 供应商卡片按钮适配
|
||||||
|
**文件**: `src/components/providers/ProviderActions.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// OpenCode 使用不同的主按钮
|
||||||
|
if (appId === "opencode") {
|
||||||
|
return (
|
||||||
|
<Button onClick={onAdd}>
|
||||||
|
{isInConfig ? t("provider.removeFromConfig") : t("provider.addToConfig")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9.3 隐藏 OpenCode 不需要的功能
|
||||||
|
|
||||||
|
在以下组件中检查 `appId !== "opencode"`:
|
||||||
|
- 代理设置面板
|
||||||
|
- 故障转移队列
|
||||||
|
- 供应商切换逻辑
|
||||||
|
|
||||||
|
### Phase 10: 国际化
|
||||||
|
|
||||||
|
#### 10.1 新增翻译 Key
|
||||||
|
**文件**: `src/locales/zh/translation.json` & `en/translation.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app.opencode": "OpenCode",
|
||||||
|
"provider.addToConfig": "添加到配置",
|
||||||
|
"provider.removeFromConfig": "从配置移除",
|
||||||
|
"provider.inConfig": "已添加",
|
||||||
|
"provider.npmPackage": "AI SDK 包",
|
||||||
|
"provider.models": "模型配置",
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键文件清单
|
||||||
|
|
||||||
|
### 后端(Rust)
|
||||||
|
| 操作 | 文件路径 |
|
||||||
|
|------|---------|
|
||||||
|
| 修改 | `src-tauri/src/app_config.rs` |
|
||||||
|
| 修改 | `src-tauri/src/database/schema.rs` |
|
||||||
|
| 修改 | `src-tauri/src/database/dao/mcp.rs` |
|
||||||
|
| 修改 | `src-tauri/src/database/dao/providers.rs` |
|
||||||
|
| 修改 | `src-tauri/src/services/provider/mod.rs` |
|
||||||
|
| 修改 | `src-tauri/src/services/mcp.rs` |
|
||||||
|
| 修改 | `src-tauri/src/commands/providers.rs` |
|
||||||
|
| 修改 | `src-tauri/src/commands/mcp.rs` |
|
||||||
|
| 修改 | `src-tauri/src/mcp/mod.rs` |
|
||||||
|
| 新建 | `src-tauri/src/opencode_config.rs` |
|
||||||
|
| 新建 | `src-tauri/src/mcp/opencode.rs` |
|
||||||
|
| 新建 | `src-tauri/src/services/provider/opencode.rs` |
|
||||||
|
|
||||||
|
### 前端(TypeScript/React)
|
||||||
|
| 操作 | 文件路径 |
|
||||||
|
|------|---------|
|
||||||
|
| 修改 | `src/types.ts` |
|
||||||
|
| 修改 | `src/lib/api/types.ts` |
|
||||||
|
| 修改 | `src/lib/api/providers.ts` |
|
||||||
|
| 修改 | `src/components/providers/ProviderActions.tsx` |
|
||||||
|
| 修改 | `src/components/providers/ProviderCard.tsx` |
|
||||||
|
| 修改 | `src/components/providers/AddProviderDialog.tsx` |
|
||||||
|
| 修改 | `src/components/providers/forms/ProviderForm.tsx` |
|
||||||
|
| 修改 | `src/App.tsx` |
|
||||||
|
| 新建 | `src/config/opencodeProviderPresets.ts` |
|
||||||
|
| 新建 | `src/components/providers/forms/OpenCodeFormFields.tsx` |
|
||||||
|
|
||||||
|
### 国际化
|
||||||
|
| 操作 | 文件路径 |
|
||||||
|
|------|---------|
|
||||||
|
| 修改 | `src/locales/zh/translation.json` |
|
||||||
|
| 修改 | `src/locales/en/translation.json` |
|
||||||
|
| 修改 | `src/locales/ja/translation.json` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证计划
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
1. OpenCode 配置读写测试
|
||||||
|
2. MCP 格式转换测试(stdio ↔ local, sse ↔ remote)
|
||||||
|
3. 供应商 CRUD 操作测试
|
||||||
|
|
||||||
|
### 集成测试
|
||||||
|
1. 添加 OpenCode 供应商 → 验证写入 `~/.config/opencode/opencode.json`
|
||||||
|
2. 删除供应商 → 验证从配置文件移除
|
||||||
|
3. MCP 同步测试 → 验证格式正确转换
|
||||||
|
4. 从 live 配置导入 → 验证正确解析
|
||||||
|
|
||||||
|
### 手动测试
|
||||||
|
1. UI 流程:添加预设 → 编辑 → 删除
|
||||||
|
2. 切换应用 Tab → OpenCode 显示正确的 UI(无代理/故障转移)
|
||||||
|
3. 托盘菜单正确显示 OpenCode 供应商
|
||||||
|
4. 深链接导入 OpenCode 供应商
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险评估
|
||||||
|
|
||||||
|
1. **数据库迁移**:需要在升级时自动执行 `ALTER TABLE` 语句
|
||||||
|
2. **配置文件冲突**:OpenCode 可能有自己的配置,需要合并而非覆盖
|
||||||
|
3. **MCP 格式差异**:`stdio` → `local` 转换需要处理边界情况
|
||||||
|
4. **UI 一致性**:OpenCode 的"添加/删除"模式需要与其他应用的"启用/切换"清晰区分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充说明
|
||||||
|
|
||||||
|
### 托盘菜单特殊处理
|
||||||
|
|
||||||
|
由于 OpenCode 采用累加式管理,托盘菜单行为需要调整:
|
||||||
|
|
||||||
|
- **现有三应用**:托盘菜单显示 `CheckMenuItem`(单选,切换当前供应商)
|
||||||
|
- **OpenCode**:显示当前所有启用的供应商(普通 MenuItem,无勾选逻辑),点击打开主界面
|
||||||
|
|
||||||
|
**修改文件**:`src-tauri/src/tray.rs`(`TRAY_SECTIONS` 常量)
|
||||||
|
|
||||||
|
### 数据库约束更新
|
||||||
|
|
||||||
|
`proxy_config` 表的 CHECK 约束需要扩展:
|
||||||
|
```sql
|
||||||
|
CHECK (app_type IN ('claude','codex','gemini','opencode'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings 结构体扩展
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/settings.rs`
|
||||||
|
|
||||||
|
需要添加:
|
||||||
|
- `current_provider_opencode: Option<String>` - 对 OpenCode 可能无意义,但保持结构一致
|
||||||
|
- `opencode_config_dir: Option<String>` - 自定义配置目录
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# CC Switch v3.10.0
|
||||||
|
|
||||||
|
> OpenCode Support, Global Proxy, Claude Rectifier & Multi-App Experience Enhancements
|
||||||
|
|
||||||
|
**[中文版 →](release-note-v3.10.0-zh.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CC Switch v3.10.0 introduces OpenCode support, becoming the fourth managed CLI application.
|
||||||
|
This release also brings global proxy settings, Claude Rectifier (thinking signature fixer), enhanced health checks, per-provider configuration, and many other important features, along with comprehensive improvements to multi-app workflows and terminal experience.
|
||||||
|
|
||||||
|
**Release Date**: 2026-01-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- OpenCode Support: Full management of providers, MCP servers, and Skills with auto-import on first launch
|
||||||
|
- Global Proxy: Configure a unified proxy for all outbound network requests
|
||||||
|
- Claude Rectifier: Thinking signature fixer for better compatibility with third-party APIs
|
||||||
|
- Enhanced Health Checks: Configurable prompts and CLI-compatible request format
|
||||||
|
- Per-Provider Config: Persistent provider-specific configuration support
|
||||||
|
- App Visibility Control: Freely show/hide apps with synchronized tray menu updates
|
||||||
|
- Terminal Improvements: Provider-specific terminal buttons, fnm path support, cross-platform safe launch
|
||||||
|
- WSL Tool Detection: Detect tool versions in WSL environment with security hardening
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Main Features
|
||||||
|
|
||||||
|
### OpenCode Support (New Fourth App)
|
||||||
|
|
||||||
|
- Complete OpenCode provider management: add, edit, switch, delete
|
||||||
|
- MCP server management: unified architecture with Claude/Codex/Gemini
|
||||||
|
- Skills support: OpenCode can also use Skills functionality
|
||||||
|
- Auto-import on first launch: automatically imports existing OpenCode configuration when detected
|
||||||
|
- Full internationalization: Chinese/English/Japanese support (#695)
|
||||||
|
|
||||||
|
### Global Proxy
|
||||||
|
|
||||||
|
- Configure a unified proxy for all outbound network requests (#596, thanks @yovinchen)
|
||||||
|
- Supports HTTP/HTTPS proxy protocols
|
||||||
|
- Suitable for network environments requiring proxy access to external APIs
|
||||||
|
|
||||||
|
### Claude Rectifier (Thinking Signature Fixer)
|
||||||
|
|
||||||
|
- Automatically fixes Claude API thinking signatures (#595, thanks @yovinchen)
|
||||||
|
- Resolves incompatible thinking block formats returned by some third-party API gateways
|
||||||
|
- Can be enabled/disabled in Advanced Settings
|
||||||
|
|
||||||
|
### Enhanced Health Checks
|
||||||
|
|
||||||
|
- Configurable custom prompts for streaming health checks (#623, thanks @yovinchen)
|
||||||
|
- Supports CLI-compatible request format for better simulation of real usage scenarios
|
||||||
|
- Improves fault detection accuracy
|
||||||
|
|
||||||
|
### Per-Provider Config
|
||||||
|
|
||||||
|
- Support for saving configuration separately for each provider (#663, thanks @yovinchen)
|
||||||
|
- Persistent configuration: provider-specific settings retained after restart
|
||||||
|
- Suitable for scenarios where different providers require different configurations
|
||||||
|
|
||||||
|
### App Visibility Control
|
||||||
|
|
||||||
|
- Freely show/hide any app (Gemini hidden by default)
|
||||||
|
- Tray menu automatically syncs visibility settings
|
||||||
|
- Hidden apps won't appear in the main interface or tray menu
|
||||||
|
|
||||||
|
### Takeover Compact Mode
|
||||||
|
|
||||||
|
- Automatically uses compact layout when 3 or more visible apps are displayed
|
||||||
|
- Optimizes space utilization in multi-app scenarios
|
||||||
|
|
||||||
|
### Terminal Improvements
|
||||||
|
|
||||||
|
- Provider-specific terminal button: one-click to use current provider in terminal (#564, thanks @kkkman22)
|
||||||
|
- `fnm` path support: automatically recognizes Node.js paths managed by fnm
|
||||||
|
- Cross-platform safe launch: improved terminal launch logic for Windows/macOS/Linux
|
||||||
|
|
||||||
|
### WSL Tool Detection
|
||||||
|
|
||||||
|
- Detect tool versions in WSL environment (#627, thanks @yovinchen)
|
||||||
|
- Added security hardening to prevent command injection risks
|
||||||
|
|
||||||
|
### Skills Preset Enhancements
|
||||||
|
|
||||||
|
- Added `baoyu-skills` preset repository
|
||||||
|
- Automatically supplements missing default repositories for out-of-the-box experience
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Experience Improvements
|
||||||
|
|
||||||
|
- Keyboard shortcuts: Press `ESC` to quickly return/close panels (#670, thanks @xxk8)
|
||||||
|
- Simplified proxy logs: cleaner and more readable output (#585, thanks @yovinchen)
|
||||||
|
- Pricing editor UX: unified `FullScreenPanel` style
|
||||||
|
- Advanced settings layout: Rectifier section moved below Failover for better logical flow
|
||||||
|
- OpenRouter compatibility mode: disabled by default, UI toggle hidden (reduces clutter)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Proxy & Failover
|
||||||
|
|
||||||
|
- Immediately switch to P1 when auto-failover is enabled (instead of waiting for next request)
|
||||||
|
|
||||||
|
### Provider Management
|
||||||
|
|
||||||
|
- Fixed stale data when reopening provider edit dialog after save (#654, thanks @YangYongAn)
|
||||||
|
- Fixed baseUrl and apiKey state not resetting when switching presets
|
||||||
|
- Fixed endpoint auto-selection state not persisting (#611, thanks @yovinchen)
|
||||||
|
- Automatically apply default color when icon color is not set
|
||||||
|
|
||||||
|
### Deep Links
|
||||||
|
|
||||||
|
- Support multi-endpoint import (#597, thanks @yovinchen)
|
||||||
|
- Prefer `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL`
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
|
||||||
|
- Skip `cmd /c` wrapper for WSL target paths (#592, thanks @cxyfer)
|
||||||
|
|
||||||
|
### Usage Templates
|
||||||
|
|
||||||
|
- Added variable hints, fixed validation issues (#628, thanks @YangYongAn)
|
||||||
|
- Prevent configuration leakage between providers
|
||||||
|
- Usage block offset automatically adapts to action button width (#613, thanks @yovinchen)
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
- Convert timeout parameters to Gemini CLI format (#580, thanks @cxyfer)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- Fixed Select dropdown rendering issues in `FullScreenPanel`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes & Considerations
|
||||||
|
|
||||||
|
- **OpenCode is a newly supported app**: OpenCode CLI must be installed first to use related features.
|
||||||
|
- **Global proxy affects all outbound requests**: including usage queries, health checks, and other network operations.
|
||||||
|
- **Rectifier is experimental**: can be disabled in Advanced Settings if issues occur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Special Thanks
|
||||||
|
|
||||||
|
Thanks to @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 for their contributions to this release!
|
||||||
|
Thanks to @libukai for designing the elegant failover-related UI!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download & Installation
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
| System | Minimum Version | Architecture |
|
||||||
|
| ------- | ------------------------------ | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 or later | x64 |
|
||||||
|
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | See table below | x64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| ---------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||||
|
| `CC-Switch-v3.10.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| -------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||||
|
|
||||||
|
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
| Distribution | Recommended Format | Installation Method |
|
||||||
|
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
|
||||||
|
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# CC Switch v3.10.0
|
||||||
|
|
||||||
|
> OpenCode サポート、グローバルプロキシ、Claude Rectifier とマルチアプリ体験の強化
|
||||||
|
|
||||||
|
**[中文版 →](release-note-v3.10.0-zh.md) | [English →](release-note-v3.10.0-en.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
CC Switch v3.10.0 では OpenCode サポートが追加され、4番目の管理対象 CLI アプリケーションとなりました。
|
||||||
|
また、グローバルプロキシ設定、Claude Rectifier(thinking 署名修正機能)、ヘルスチェックの強化、プロバイダー別設定など、多くの重要な機能が追加され、マルチアプリワークフローとターミナル体験が全面的に改善されました。
|
||||||
|
|
||||||
|
**リリース日**: 2026-01-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ハイライト
|
||||||
|
|
||||||
|
- OpenCode サポート:プロバイダー、MCP サーバー、Skills の完全管理、初回起動時の自動インポート
|
||||||
|
- グローバルプロキシ:すべての送信ネットワークリクエストに統一プロキシを設定
|
||||||
|
- Claude Rectifier:thinking 署名修正機能、サードパーティ API との互換性向上
|
||||||
|
- ヘルスチェック強化:カスタムプロンプト設定、CLI 互換リクエスト形式
|
||||||
|
- プロバイダー別設定:プロバイダー固有の設定の永続化をサポート
|
||||||
|
- アプリ表示制御:アプリの表示/非表示を自由に設定、トレイメニューと同期
|
||||||
|
- ターミナル改善:プロバイダー専用ターミナルボタン、fnm パスサポート、クロスプラットフォーム安全起動
|
||||||
|
- WSL ツール検出:WSL 環境でのツールバージョン検出とセキュリティ強化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主な機能
|
||||||
|
|
||||||
|
### OpenCode サポート(新しい4番目のアプリ)
|
||||||
|
|
||||||
|
- 完全な OpenCode プロバイダー管理:追加、編集、切り替え、削除
|
||||||
|
- MCP サーバー管理:Claude/Codex/Gemini と統一されたアーキテクチャ
|
||||||
|
- Skills サポート:OpenCode でも Skills 機能を使用可能
|
||||||
|
- 初回起動時の自動インポート:既存の OpenCode 設定を検出すると自動的にインポート
|
||||||
|
- 完全な国際化:中国語/英語/日本語サポート (#695)
|
||||||
|
|
||||||
|
### グローバルプロキシ
|
||||||
|
|
||||||
|
- すべての送信ネットワークリクエストに統一プロキシを設定 (#596、@yovinchen に感謝)
|
||||||
|
- HTTP/HTTPS プロキシプロトコルをサポート
|
||||||
|
- 外部 API へのプロキシアクセスが必要なネットワーク環境に適用
|
||||||
|
|
||||||
|
### Claude Rectifier(Thinking 署名修正機能)
|
||||||
|
|
||||||
|
- Claude API の thinking 署名を自動修正 (#595、@yovinchen に感謝)
|
||||||
|
- 一部のサードパーティ API ゲートウェイが返す互換性のない thinking ブロック形式を解決
|
||||||
|
- 詳細設定で有効/無効を切り替え可能
|
||||||
|
|
||||||
|
### ヘルスチェック強化
|
||||||
|
|
||||||
|
- ストリーミングヘルスチェック用のカスタムプロンプトを設定可能 (#623、@yovinchen に感謝)
|
||||||
|
- CLI 互換リクエスト形式をサポートし、実際の使用シナリオをより良くシミュレート
|
||||||
|
- 障害検出の精度を向上
|
||||||
|
|
||||||
|
### プロバイダー別設定
|
||||||
|
|
||||||
|
- 各プロバイダーごとに設定を個別に保存可能 (#663、@yovinchen に感謝)
|
||||||
|
- 設定の永続化:再起動後もプロバイダー固有の設定を保持
|
||||||
|
- 異なるプロバイダーに異なる設定が必要なシナリオに適用
|
||||||
|
|
||||||
|
### アプリ表示制御
|
||||||
|
|
||||||
|
- 任意のアプリを自由に表示/非表示(Gemini はデフォルトで非表示)
|
||||||
|
- トレイメニューは表示設定と自動的に同期
|
||||||
|
- 非表示のアプリはメインインターフェースとトレイメニューに表示されない
|
||||||
|
|
||||||
|
### Takeover コンパクトモード
|
||||||
|
|
||||||
|
- 3つ以上の表示アプリがある場合、自動的にコンパクトレイアウトを使用
|
||||||
|
- マルチアプリシナリオでのスペース利用を最適化
|
||||||
|
|
||||||
|
### ターミナル改善
|
||||||
|
|
||||||
|
- プロバイダー専用ターミナルボタン:ワンクリックでターミナルで現在のプロバイダーを使用 (#564、@kkkman22 に感謝)
|
||||||
|
- `fnm` パスサポート:fnm で管理された Node.js パスを自動認識
|
||||||
|
- クロスプラットフォーム安全起動:Windows/macOS/Linux のターミナル起動ロジックを改善
|
||||||
|
|
||||||
|
### WSL ツール検出
|
||||||
|
|
||||||
|
- WSL 環境でツールバージョンを検出 (#627、@yovinchen に感謝)
|
||||||
|
- コマンドインジェクションリスクを防ぐためのセキュリティ強化を追加
|
||||||
|
|
||||||
|
### Skills プリセット強化
|
||||||
|
|
||||||
|
- `baoyu-skills` プリセットリポジトリを追加
|
||||||
|
- 不足しているデフォルトリポジトリを自動補完し、すぐに使える状態を確保
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 体験の改善
|
||||||
|
|
||||||
|
- キーボードショートカット:`ESC` を押してパネルをすばやく戻る/閉じる (#670、@xxk8 に感謝)
|
||||||
|
- プロキシログの簡素化:より明確で読みやすい出力 (#585、@yovinchen に感謝)
|
||||||
|
- 価格エディター UX:統一された `FullScreenPanel` スタイル
|
||||||
|
- 詳細設定レイアウト:Rectifier セクションを Failover の下に移動し、論理的な流れを改善
|
||||||
|
- OpenRouter 互換モード:デフォルトで無効、UI トグルを非表示(煩雑さを軽減)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## バグ修正
|
||||||
|
|
||||||
|
### プロキシとフェイルオーバー
|
||||||
|
|
||||||
|
- 自動フェイルオーバーが有効な場合、すぐに P1 に切り替え(次のリクエストを待たずに)
|
||||||
|
|
||||||
|
### プロバイダー管理
|
||||||
|
|
||||||
|
- 保存後にプロバイダー編集ダイアログを再度開いたときにデータが古い問題を修正 (#654、@YangYongAn に感謝)
|
||||||
|
- プリセット切り替え時に baseUrl と apiKey の状態がリセットされない問題を修正
|
||||||
|
- エンドポイント自動選択状態が永続化されない問題を修正 (#611、@yovinchen に感謝)
|
||||||
|
- アイコンカラーが設定されていない場合、デフォルトカラーを自動適用
|
||||||
|
|
||||||
|
### ディープリンク
|
||||||
|
|
||||||
|
- マルチエンドポイントインポートをサポート (#597、@yovinchen に感謝)
|
||||||
|
- `GEMINI_BASE_URL` より `GOOGLE_GEMINI_BASE_URL` を優先
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
|
||||||
|
- WSL ターゲットパスの `cmd /c` ラッパーをスキップ (#592、@cxyfer に感謝)
|
||||||
|
|
||||||
|
### 使用量テンプレート
|
||||||
|
|
||||||
|
- 変数ヒントを追加、検証の問題を修正 (#628、@YangYongAn に感謝)
|
||||||
|
- プロバイダー間での設定漏洩を防止
|
||||||
|
- 使用量ブロックのオフセットがアクションボタンの幅に自動適応 (#613、@yovinchen に感謝)
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
- タイムアウトパラメータを Gemini CLI 形式に変換 (#580、@cxyfer に感謝)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- `FullScreenPanel` での Select ドロップダウンのレンダリング問題を修正
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
- **OpenCode は新しくサポートされたアプリです**:関連機能を使用するには、まず OpenCode CLI をインストールする必要があります。
|
||||||
|
- **グローバルプロキシはすべての送信リクエストに影響します**:使用量クエリ、ヘルスチェックなどのネットワーク操作を含みます。
|
||||||
|
- **Rectifier は実験的機能です**:問題が発生した場合は、詳細設定で無効にできます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特別な感謝
|
||||||
|
|
||||||
|
@yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 の皆様、このリリースへの貢献に感謝します!
|
||||||
|
@libukai 様、エレガントなフェイルオーバー関連 UI のデザインに感謝します!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ダウンロードとインストール
|
||||||
|
|
||||||
|
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
|
||||||
|
|
||||||
|
### システム要件
|
||||||
|
|
||||||
|
| システム | 最小バージョン | アーキテクチャ |
|
||||||
|
| -------- | -------------------------------- | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 以降 | x64 |
|
||||||
|
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 下表参照 | x64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| ---------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||||
|
| `CC-Switch-v3.10.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| -------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||||
|
|
||||||
|
> **注意**:作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||||
|
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
|
||||||
|
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# CC Switch v3.10.0
|
||||||
|
|
||||||
|
> OpenCode 支持、全局代理、Claude Rectifier 与多应用体验增强
|
||||||
|
|
||||||
|
**[English →](release-note-v3.10.0-en.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
CC Switch v3.10.0 新增 OpenCode 支持,成为第四个受管理的 CLI 应用。
|
||||||
|
同时带来全局代理设置、Claude Rectifier(thinking 签名修正器)、健康检查增强、按供应商配置等多项重要功能,并对多应用工作流与终端体验做了全面改进。
|
||||||
|
|
||||||
|
**发布日期**:2026-01-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 重点内容
|
||||||
|
|
||||||
|
- OpenCode 支持:供应商、MCP 服务器、Skills 全面管理,首次启动自动导入
|
||||||
|
- 全局代理:为出站网络请求统一配置代理
|
||||||
|
- Claude Rectifier:thinking 签名修正器,兼容更多第三方 API
|
||||||
|
- 健康检查增强:可配置提示词、CLI 兼容请求
|
||||||
|
- 按供应商配置:支持供应商特定配置的持久化
|
||||||
|
- 应用可见性控制:自由显示/隐藏应用,托盘菜单同步更新
|
||||||
|
- 终端改进:供应商专属终端按钮、fnm 路径支持、跨平台安全启动
|
||||||
|
- WSL 工具检测:在 WSL 环境检测工具版本,并增加安全加固
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
### OpenCode 支持(新增第四应用)
|
||||||
|
|
||||||
|
- 完整的 OpenCode 供应商管理:新增、编辑、切换、删除
|
||||||
|
- MCP 服务器管理:与 Claude/Codex/Gemini 统一架构
|
||||||
|
- Skills 支持:OpenCode 也可使用 Skills 功能
|
||||||
|
- 首次启动自动导入:检测到已有 OpenCode 配置时自动导入
|
||||||
|
- 完整国际化:中/英/日三语支持(#695)
|
||||||
|
|
||||||
|
### 全局代理(Global Proxy)
|
||||||
|
|
||||||
|
- 为所有出站网络请求配置统一代理(#596,感谢 @yovinchen)
|
||||||
|
- 支持 HTTP/HTTPS 代理协议
|
||||||
|
- 适用于需要代理访问外部 API 的网络环境
|
||||||
|
|
||||||
|
### Claude Rectifier(Thinking 签名修正器)
|
||||||
|
|
||||||
|
- 自动修正 Claude API 的 thinking 签名(#595,感谢 @yovinchen)
|
||||||
|
- 解决部分第三方 API 网关返回的 thinking 块格式不兼容问题
|
||||||
|
- 在高级设置中可开启/关闭
|
||||||
|
|
||||||
|
### 健康检查增强
|
||||||
|
|
||||||
|
- 可配置自定义提示词(prompt)用于流式健康检查(#623,感谢 @yovinchen)
|
||||||
|
- 支持 CLI 兼容请求格式,更好地模拟真实使用场景
|
||||||
|
- 提升故障检测的准确性
|
||||||
|
|
||||||
|
### 按供应商配置(Per-Provider Config)
|
||||||
|
|
||||||
|
- 支持为每个供应商单独保存配置(#663,感谢 @yovinchen)
|
||||||
|
- 配置持久化:重启后保留供应商专属设置
|
||||||
|
- 适用于不同供应商需要不同配置的场景
|
||||||
|
|
||||||
|
### 应用可见性控制
|
||||||
|
|
||||||
|
- 自由显示/隐藏任意应用(Gemini 默认隐藏)
|
||||||
|
- 托盘菜单自动同步可见性设置
|
||||||
|
- 隐藏的应用不会出现在主界面和托盘菜单中
|
||||||
|
|
||||||
|
### Takeover Compact Mode
|
||||||
|
|
||||||
|
- 当显示 3 个及以上可见应用时,自动使用紧凑布局
|
||||||
|
- 优化多应用场景下的空间利用
|
||||||
|
|
||||||
|
### 终端改进
|
||||||
|
|
||||||
|
- 供应商专属终端按钮:一键在终端中使用当前供应商(#564,感谢 @kkkman22)
|
||||||
|
- `fnm` 路径支持:自动识别 fnm 管理的 Node.js 路径
|
||||||
|
- 跨平台安全启动:改进 Windows/macOS/Linux 的终端启动逻辑
|
||||||
|
|
||||||
|
### WSL 工具检测
|
||||||
|
|
||||||
|
- 在 WSL 环境中检测工具版本(#627,感谢 @yovinchen)
|
||||||
|
- 增加安全加固,防止命令注入风险
|
||||||
|
|
||||||
|
### Skills 预设增强
|
||||||
|
|
||||||
|
- 新增 `baoyu-skills` 预设仓库
|
||||||
|
- 自动补充缺失的默认仓库,确保开箱即用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 体验优化
|
||||||
|
|
||||||
|
- 键盘快捷键:按 `ESC` 快速返回/关闭面板(#670,感谢 @xxk8)
|
||||||
|
- 代理日志简化:输出更清晰易读(#585,感谢 @yovinchen)
|
||||||
|
- 定价编辑器 UX:统一使用 `FullScreenPanel` 风格
|
||||||
|
- 高级设置布局:Rectifier 区块移至 Failover 下方,逻辑更顺畅
|
||||||
|
- OpenRouter 兼容模式:默认禁用,UI 开关隐藏(减少干扰)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复
|
||||||
|
|
||||||
|
### 代理与故障切换
|
||||||
|
|
||||||
|
- 启用自动故障切换时立即切换到 P1(而非等待下次请求)
|
||||||
|
|
||||||
|
### 供应商管理
|
||||||
|
|
||||||
|
- 修复供应商编辑对话框保存后重新打开时数据过时的问题(#654,感谢 @YangYongAn)
|
||||||
|
- 修复切换预设时 baseUrl 和 apiKey 状态未重置的问题
|
||||||
|
- 修复端点自动选择状态未持久化的问题(#611,感谢 @yovinchen)
|
||||||
|
- 未设置图标颜色时自动应用默认颜色
|
||||||
|
|
||||||
|
### 深链接
|
||||||
|
|
||||||
|
- 支持多端点导入(#597,感谢 @yovinchen)
|
||||||
|
- 优先使用 `GOOGLE_GEMINI_BASE_URL` 而非 `GEMINI_BASE_URL`
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
|
||||||
|
- WSL 目标路径跳过 `cmd /c` 包裹(#592,感谢 @cxyfer)
|
||||||
|
|
||||||
|
### 用量模板
|
||||||
|
|
||||||
|
- 新增变量提示,修复验证问题(#628,感谢 @YangYongAn)
|
||||||
|
- 防止配置在供应商之间泄漏
|
||||||
|
- 用量区块偏移量根据操作按钮宽度自动适应(#613,感谢 @yovinchen)
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
- 超时参数转换为 Gemini CLI 格式(#580,感谢 @cxyfer)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- 修复 `FullScreenPanel` 中 Select 下拉框渲染问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 说明与注意事项
|
||||||
|
|
||||||
|
- **OpenCode 为新支持的应用**:需要先安装 OpenCode CLI 才能使用相关功能。
|
||||||
|
- **全局代理会影响所有出站请求**:包括用量查询、健康检查等网络操作。
|
||||||
|
- **Rectifier 功能为实验性**:如遇问题可在高级设置中关闭。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特别感谢
|
||||||
|
|
||||||
|
感谢 @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 为本版本做出的贡献!
|
||||||
|
感谢 @libukai 设计的故障转移相关 UI,非常优雅!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载与安装
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
| 系统 | 最低版本 | 架构 |
|
||||||
|
| ------- | ----------------------------- | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 及以上 | x64 |
|
||||||
|
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 见下表 | x64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| ---------------------------------------- | ----------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||||
|
| `CC-Switch-v3.10.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| -------------------------------- | --------------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||||
|
|
||||||
|
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||||
|
|
||||||
|
### Homebrew(macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
| 发行版 | 推荐格式 | 安装方式 |
|
||||||
|
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||||
|
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.9.1",
|
"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,12 @@
|
|||||||
"@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-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 +74,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",
|
||||||
|
|||||||
Generated
+97
@@ -59,6 +59,9 @@ 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-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 +74,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 +110,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)
|
||||||
@@ -1117,6 +1126,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 +1196,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 +1358,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 +1475,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 +2046,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 +2265,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==}
|
||||||
@@ -3905,6 +3963,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 +4054,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 +4853,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 视图)
|
||||||
|
- 会话清理/归档(磁盘管理)
|
||||||
|
|
||||||
|
---
|
||||||
Generated
+1
-2
@@ -701,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.1"
|
version = "3.10.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -727,7 +727,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
"sha2",
|
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-deep-link",
|
"tauri-plugin-deep-link",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.1"
|
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"
|
||||||
@@ -61,7 +61,6 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
|||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
rust_decimal = "1.33"
|
rust_decimal = "1.33"
|
||||||
uuid = { version = "1.11", features = ["v4"] }
|
uuid = { version = "1.11", features = ["v4"] }
|
||||||
sha2 = "0.10"
|
|
||||||
|
|
||||||
[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"
|
||||||
|
|||||||
+26
-1
@@ -1,3 +1,28 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build();
|
||||||
|
|
||||||
|
// Windows: Embed Common Controls v6 manifest for test binaries
|
||||||
|
//
|
||||||
|
// When running `cargo test`, the generated test executables don't include
|
||||||
|
// the standard Tauri application manifest. Without Common Controls v6,
|
||||||
|
// `tauri::test` calls fail with STATUS_ENTRYPOINT_NOT_FOUND.
|
||||||
|
//
|
||||||
|
// This workaround:
|
||||||
|
// 1. Embeds the manifest into test binaries via /MANIFEST:EMBED
|
||||||
|
// 2. Uses /MANIFEST:NO for the main binary to avoid duplicate resources
|
||||||
|
// (Tauri already handles manifest embedding for the app binary)
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let manifest_path = std::path::PathBuf::from(
|
||||||
|
std::env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR"),
|
||||||
|
)
|
||||||
|
.join("common-controls.manifest");
|
||||||
|
let manifest_arg = format!("/MANIFESTINPUT:{}", manifest_path.display());
|
||||||
|
|
||||||
|
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
|
||||||
|
println!("cargo:rustc-link-arg={}", manifest_arg);
|
||||||
|
// Avoid duplicate manifest resources in binary builds.
|
||||||
|
println!("cargo:rustc-link-arg-bins=/MANIFEST:NO");
|
||||||
|
println!("cargo:rerun-if-changed={}", manifest_path.display());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32"
|
||||||
|
name="Microsoft.Windows.Common-Controls"
|
||||||
|
version="6.0.0.0"
|
||||||
|
processorArchitecture="*"
|
||||||
|
publicKeyToken="6595b64144ccf1df"
|
||||||
|
language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
</assembly>
|
||||||
@@ -13,6 +13,8 @@ pub struct McpApps {
|
|||||||
pub codex: bool,
|
pub codex: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gemini: bool,
|
pub gemini: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub opencode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpApps {
|
impl McpApps {
|
||||||
@@ -22,6 +24,7 @@ impl McpApps {
|
|||||||
AppType::Claude => self.claude,
|
AppType::Claude => self.claude,
|
||||||
AppType::Codex => self.codex,
|
AppType::Codex => self.codex,
|
||||||
AppType::Gemini => self.gemini,
|
AppType::Gemini => self.gemini,
|
||||||
|
AppType::OpenCode => self.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ impl McpApps {
|
|||||||
AppType::Claude => self.claude = enabled,
|
AppType::Claude => self.claude = enabled,
|
||||||
AppType::Codex => self.codex = enabled,
|
AppType::Codex => self.codex = enabled,
|
||||||
AppType::Gemini => self.gemini = enabled,
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
AppType::OpenCode => self.opencode = enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +50,15 @@ impl McpApps {
|
|||||||
if self.gemini {
|
if self.gemini {
|
||||||
apps.push(AppType::Gemini);
|
apps.push(AppType::Gemini);
|
||||||
}
|
}
|
||||||
|
if self.opencode {
|
||||||
|
apps.push(AppType::OpenCode);
|
||||||
|
}
|
||||||
apps
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否所有应用都未启用
|
/// 检查是否所有应用都未启用
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
!self.claude && !self.codex && !self.gemini
|
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +71,8 @@ pub struct SkillApps {
|
|||||||
pub codex: bool,
|
pub codex: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gemini: bool,
|
pub gemini: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub opencode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkillApps {
|
impl SkillApps {
|
||||||
@@ -73,6 +82,7 @@ impl SkillApps {
|
|||||||
AppType::Claude => self.claude,
|
AppType::Claude => self.claude,
|
||||||
AppType::Codex => self.codex,
|
AppType::Codex => self.codex,
|
||||||
AppType::Gemini => self.gemini,
|
AppType::Gemini => self.gemini,
|
||||||
|
AppType::OpenCode => self.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +92,7 @@ impl SkillApps {
|
|||||||
AppType::Claude => self.claude = enabled,
|
AppType::Claude => self.claude = enabled,
|
||||||
AppType::Codex => self.codex = enabled,
|
AppType::Codex => self.codex = enabled,
|
||||||
AppType::Gemini => self.gemini = enabled,
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
AppType::OpenCode => self.opencode = enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,12 +108,15 @@ impl SkillApps {
|
|||||||
if self.gemini {
|
if self.gemini {
|
||||||
apps.push(AppType::Gemini);
|
apps.push(AppType::Gemini);
|
||||||
}
|
}
|
||||||
|
if self.opencode {
|
||||||
|
apps.push(AppType::OpenCode);
|
||||||
|
}
|
||||||
apps
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否所有应用都未启用
|
/// 检查是否所有应用都未启用
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
!self.claude && !self.codex && !self.gemini
|
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 仅启用指定应用(其他应用设为禁用)
|
/// 仅启用指定应用(其他应用设为禁用)
|
||||||
@@ -205,6 +219,9 @@ pub struct McpRoot {
|
|||||||
pub codex: McpConfig,
|
pub codex: McpConfig,
|
||||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
pub gemini: McpConfig,
|
pub gemini: McpConfig,
|
||||||
|
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub opencode: McpConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for McpRoot {
|
impl Default for McpRoot {
|
||||||
@@ -216,6 +233,7 @@ impl Default for McpRoot {
|
|||||||
claude: McpConfig::default(),
|
claude: McpConfig::default(),
|
||||||
codex: McpConfig::default(),
|
codex: McpConfig::default(),
|
||||||
gemini: McpConfig::default(),
|
gemini: McpConfig::default(),
|
||||||
|
opencode: McpConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +254,8 @@ pub struct PromptRoot {
|
|||||||
pub codex: PromptConfig,
|
pub codex: PromptConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gemini: PromptConfig,
|
pub gemini: PromptConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub opencode: PromptConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||||
@@ -249,7 +269,8 @@ use crate::provider::ProviderManager;
|
|||||||
pub enum AppType {
|
pub enum AppType {
|
||||||
Claude,
|
Claude,
|
||||||
Codex,
|
Codex,
|
||||||
Gemini, // 新增
|
Gemini,
|
||||||
|
OpenCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppType {
|
impl AppType {
|
||||||
@@ -257,9 +278,29 @@ impl AppType {
|
|||||||
match self {
|
match self {
|
||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini", // 新增
|
AppType::Gemini => "gemini",
|
||||||
|
AppType::OpenCode => "opencode",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if this app uses additive mode
|
||||||
|
///
|
||||||
|
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
|
||||||
|
/// - Additive mode (true): All providers are written to live config (OpenCode)
|
||||||
|
pub fn is_additive_mode(&self) -> bool {
|
||||||
|
matches!(self, AppType::OpenCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return an iterator over all app types
|
||||||
|
pub fn all() -> impl Iterator<Item = AppType> {
|
||||||
|
[
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for AppType {
|
impl FromStr for AppType {
|
||||||
@@ -270,11 +311,12 @@ impl FromStr for AppType {
|
|||||||
match normalized.as_str() {
|
match normalized.as_str() {
|
||||||
"claude" => Ok(AppType::Claude),
|
"claude" => Ok(AppType::Claude),
|
||||||
"codex" => Ok(AppType::Codex),
|
"codex" => Ok(AppType::Codex),
|
||||||
"gemini" => Ok(AppType::Gemini), // 新增
|
"gemini" => Ok(AppType::Gemini),
|
||||||
|
"opencode" => Ok(AppType::OpenCode),
|
||||||
other => Err(AppError::localized(
|
other => Err(AppError::localized(
|
||||||
"unsupported_app",
|
"unsupported_app",
|
||||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
|
||||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,6 +333,9 @@ pub struct CommonConfigSnippets {
|
|||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub gemini: Option<String>,
|
pub gemini: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opencode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonConfigSnippets {
|
impl CommonConfigSnippets {
|
||||||
@@ -300,6 +345,7 @@ impl CommonConfigSnippets {
|
|||||||
AppType::Claude => self.claude.as_ref(),
|
AppType::Claude => self.claude.as_ref(),
|
||||||
AppType::Codex => self.codex.as_ref(),
|
AppType::Codex => self.codex.as_ref(),
|
||||||
AppType::Gemini => self.gemini.as_ref(),
|
AppType::Gemini => self.gemini.as_ref(),
|
||||||
|
AppType::OpenCode => self.opencode.as_ref(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +355,7 @@ impl CommonConfigSnippets {
|
|||||||
AppType::Claude => self.claude = snippet,
|
AppType::Claude => self.claude = snippet,
|
||||||
AppType::Codex => self.codex = snippet,
|
AppType::Codex => self.codex = snippet,
|
||||||
AppType::Gemini => self.gemini = snippet,
|
AppType::Gemini => self.gemini = snippet,
|
||||||
|
AppType::OpenCode => self.opencode = snippet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,7 +394,8 @@ impl Default for MultiAppConfig {
|
|||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||||
|
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
version: 2,
|
version: 2,
|
||||||
@@ -506,6 +554,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &self.mcp.claude,
|
AppType::Claude => &self.mcp.claude,
|
||||||
AppType::Codex => &self.mcp.codex,
|
AppType::Codex => &self.mcp.codex,
|
||||||
AppType::Gemini => &self.mcp.gemini,
|
AppType::Gemini => &self.mcp.gemini,
|
||||||
|
AppType::OpenCode => &self.mcp.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +564,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &mut self.mcp.claude,
|
AppType::Claude => &mut self.mcp.claude,
|
||||||
AppType::Codex => &mut self.mcp.codex,
|
AppType::Codex => &mut self.mcp.codex,
|
||||||
AppType::Gemini => &mut self.mcp.gemini,
|
AppType::Gemini => &mut self.mcp.gemini,
|
||||||
|
AppType::OpenCode => &mut self.mcp.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +578,7 @@ impl MultiAppConfig {
|
|||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||||
|
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -547,6 +598,7 @@ impl MultiAppConfig {
|
|||||||
if !self.prompts.claude.prompts.is_empty()
|
if !self.prompts.claude.prompts.is_empty()
|
||||||
|| !self.prompts.codex.prompts.is_empty()
|
|| !self.prompts.codex.prompts.is_empty()
|
||||||
|| !self.prompts.gemini.prompts.is_empty()
|
|| !self.prompts.gemini.prompts.is_empty()
|
||||||
|
|| !self.prompts.opencode.prompts.is_empty()
|
||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -554,7 +606,12 @@ impl MultiAppConfig {
|
|||||||
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||||
|
|
||||||
let mut imported = false;
|
let mut imported = false;
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
// 复用已有的单应用导入逻辑
|
// 复用已有的单应用导入逻辑
|
||||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||||
imported = true;
|
imported = true;
|
||||||
@@ -623,6 +680,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &mut config.prompts.claude.prompts,
|
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||||
|
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||||
};
|
};
|
||||||
|
|
||||||
prompts.insert(id, prompt);
|
prompts.insert(id, prompt);
|
||||||
@@ -656,6 +714,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &self.mcp.claude.servers,
|
AppType::Claude => &self.mcp.claude.servers,
|
||||||
AppType::Codex => &self.mcp.codex.servers,
|
AppType::Codex => &self.mcp.codex.servers,
|
||||||
AppType::Gemini => &self.mcp.gemini.servers,
|
AppType::Gemini => &self.mcp.gemini.servers,
|
||||||
|
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (id, entry) in old_servers {
|
for (id, entry) in old_servers {
|
||||||
|
|||||||
@@ -2,21 +2,14 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
|
atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file,
|
||||||
|
write_text_file,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// 获取用户主目录,带回退和日志
|
|
||||||
fn get_home_dir() -> PathBuf {
|
|
||||||
dirs::home_dir().unwrap_or_else(|| {
|
|
||||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
|
||||||
PathBuf::from(".")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Codex 配置目录路径
|
/// 获取 Codex 配置目录路径
|
||||||
pub fn get_codex_config_dir() -> PathBuf {
|
pub fn get_codex_config_dir() -> PathBuf {
|
||||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
|||||||
|
|
||||||
Ok(ConfigStatus { exists, path })
|
Ok(ConfigStatus { exists, path })
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
let config_path = crate::opencode_config::get_opencode_config_path();
|
||||||
|
let exists = config_path.exists();
|
||||||
|
let path = crate::opencode_config::get_opencode_dir()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(ConfigStatus { exists, path })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +76,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
|||||||
AppType::Claude => config::get_claude_config_dir(),
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
|
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(dir.to_string_lossy().to_string())
|
Ok(dir.to_string_lossy().to_string())
|
||||||
@@ -79,6 +89,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
|||||||
AppType::Claude => config::get_claude_config_dir(),
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
|
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !config_dir.exists() {
|
if !config_dir.exists() {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
use crate::database::FailoverQueueItem;
|
use crate::database::FailoverQueueItem;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
/// 获取故障转移队列
|
/// 获取故障转移队列
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -75,6 +77,7 @@ pub async fn get_auto_failover_enabled(
|
|||||||
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_auto_failover_enabled(
|
pub async fn set_auto_failover_enabled(
|
||||||
|
app: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
app_type: String,
|
app_type: String,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
@@ -83,6 +86,47 @@ pub async fn set_auto_failover_enabled(
|
|||||||
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 强一致语义:开启故障转移后立即切到队列 P1(并确保队列非空)
|
||||||
|
//
|
||||||
|
// 说明:
|
||||||
|
// - 仅在 enabled=true 时执行“切到 P1”
|
||||||
|
// - 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户在 UI 上陷入死锁(无法先加队列再开启)
|
||||||
|
let p1_provider_id = if enabled {
|
||||||
|
let mut queue = state
|
||||||
|
.db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if queue.is_empty() {
|
||||||
|
let app_enum = crate::app_config::AppType::from_str(&app_type)
|
||||||
|
.map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||||
|
|
||||||
|
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_enum)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let Some(current_id) = current_id else {
|
||||||
|
return Err("故障转移队列为空,且未设置当前供应商,无法开启故障转移".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.add_to_failover_queue(&app_type, ¤t_id)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue = state
|
||||||
|
.db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue
|
||||||
|
.first()
|
||||||
|
.map(|item| item.provider_id.clone())
|
||||||
|
.ok_or_else(|| "故障转移队列为空,无法开启故障转移".to_string())?
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
// 读取当前配置
|
// 读取当前配置
|
||||||
let mut config = state
|
let mut config = state
|
||||||
.db
|
.db
|
||||||
@@ -98,5 +142,30 @@ pub async fn set_auto_failover_enabled(
|
|||||||
.db
|
.db
|
||||||
.update_proxy_config_for_app(config)
|
.update_proxy_config_for_app(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 开启后立即切到 P1:更新 is_current + 本地 settings + Live 备份(接管模式下)
|
||||||
|
if enabled {
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.switch_proxy_target(&app_type, &p1_provider_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 发射 provider-switched 事件(让前端刷新当前供应商)
|
||||||
|
let event_data = serde_json::json!({
|
||||||
|
"appType": app_type,
|
||||||
|
"providerId": p1_provider_id,
|
||||||
|
"source": "failoverEnabled"
|
||||||
|
});
|
||||||
|
let _ = app.emit("provider-switched", event_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新托盘菜单,确保状态同步
|
||||||
|
if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) {
|
||||||
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
|
let _ = tray.set_menu(Some(new_menu));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ pub async fn upsert_mcp_server_in_config(
|
|||||||
new_server.apps.claude = true;
|
new_server.apps.claude = true;
|
||||||
new_server.apps.codex = true;
|
new_server.apps.codex = true;
|
||||||
new_server.apps.gemini = true;
|
new_server.apps.gemini = true;
|
||||||
|
new_server.apps.opencode = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
McpService::upsert_server(&state, new_server)
|
McpService::upsert_server(&state, new_server)
|
||||||
@@ -200,5 +201,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, S
|
|||||||
total += McpService::import_from_claude(&state).unwrap_or(0);
|
total += McpService::import_from_claude(&state).unwrap_or(0);
|
||||||
total += McpService::import_from_codex(&state).unwrap_or(0);
|
total += McpService::import_from_codex(&state).unwrap_or(0);
|
||||||
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
||||||
|
total += McpService::import_from_opencode(&state).unwrap_or(0);
|
||||||
Ok(total)
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
|||||||
+351
-90
@@ -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,10 +319,10 @@ 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
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -322,6 +346,19 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 fnm 路径支持
|
||||||
|
let fnm_base = home.join(".local/state/fnm_multishells");
|
||||||
|
if fnm_base.exists() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let bin_path = entry.path().join("bin");
|
||||||
|
if bin_path.exists() {
|
||||||
|
search_paths.push(bin_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 扫描 nvm 目录下的所有 node 版本
|
// 扫描 nvm 目录下的所有 node 版本
|
||||||
let nvm_base = home.join(".nvm/versions/node");
|
let nvm_base = home.join(".nvm/versions/node");
|
||||||
if nvm_base.exists() {
|
if nvm_base.exists() {
|
||||||
@@ -335,6 +372,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") {
|
||||||
@@ -392,6 +437,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,
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
@@ -526,18 +572,15 @@ fn launch_terminal_with_env(
|
|||||||
// 创建并写入配置文件
|
// 创建并写入配置文件
|
||||||
write_claude_config(&config_file, &env_vars)?;
|
write_claude_config(&config_file, &env_vars)?;
|
||||||
|
|
||||||
// 转义配置文件路径用于 shell
|
|
||||||
let config_path_escaped = escape_shell_path(&config_file);
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
launch_macos_terminal(&config_file)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
launch_linux_terminal(&config_file, &config_path_escaped)?;
|
launch_linux_terminal(&config_file)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,115 +614,278 @@ fn write_claude_config(
|
|||||||
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 转义 shell 路径
|
/// macOS: 根据用户首选终端启动
|
||||||
fn escape_shell_path(path: &std::path::Path) -> String {
|
|
||||||
path.to_string_lossy()
|
|
||||||
.replace('\\', "\\\\")
|
|
||||||
.replace('"', "\\\"")
|
|
||||||
.replace('$', "\\$")
|
|
||||||
.replace(' ', "\\ ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成 bash 包装脚本,用于清理临时文件
|
|
||||||
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// macOS: 使用 Terminal.app 启动
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn launch_macos_terminal(
|
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||||
config_file: &std::path::Path,
|
use std::os::unix::fs::PermissionsExt;
|
||||||
config_path_escaped: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
|
let preferred = crate::settings::get_preferred_terminal();
|
||||||
|
let terminal = preferred.as_deref().unwrap_or("terminal");
|
||||||
|
|
||||||
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||||
|
let config_path = config_file.to_string_lossy();
|
||||||
|
|
||||||
let script = format!(
|
// Write the shell script to a temp file
|
||||||
r#"tell application "Terminal"
|
let script_content = format!(
|
||||||
activate
|
r#"#!/bin/bash
|
||||||
do script "{}"
|
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||||
end tell"#,
|
echo "Using provider-specific claude config:"
|
||||||
shell_script.replace('\"', "\\\"")
|
echo "{config_path}"
|
||||||
|
claude --settings "{config_path}"
|
||||||
|
exec bash --norc --noprofile
|
||||||
|
"#,
|
||||||
|
config_path = config_path,
|
||||||
|
script_file = script_file.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
Command::new("osascript")
|
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||||
|
|
||||||
|
// Make script executable
|
||||||
|
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||||
|
|
||||||
|
// Try the preferred terminal first, fall back to Terminal.app if it fails
|
||||||
|
// Note: Kitty doesn't need the -e flag, others do
|
||||||
|
let result = match terminal {
|
||||||
|
"iterm2" => launch_macos_iterm2(&script_file),
|
||||||
|
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
|
||||||
|
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||||||
|
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
||||||
|
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
|
||||||
|
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||||||
|
};
|
||||||
|
|
||||||
|
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
|
||||||
|
if result.is_err() && terminal != "terminal" {
|
||||||
|
log::warn!(
|
||||||
|
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
|
||||||
|
terminal,
|
||||||
|
result.as_ref().err()
|
||||||
|
);
|
||||||
|
return launch_macos_terminal_app(&script_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// macOS: Terminal.app
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let applescript = format!(
|
||||||
|
r#"tell application "Terminal"
|
||||||
|
activate
|
||||||
|
do script "bash '{}'"
|
||||||
|
end tell"#,
|
||||||
|
script_file.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = Command::new("osascript")
|
||||||
.arg("-e")
|
.arg("-e")
|
||||||
.arg(&script)
|
.arg(&applescript)
|
||||||
.spawn()
|
.output()
|
||||||
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
|
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!(
|
||||||
|
"Terminal.app 执行失败 (exit code: {:?}): {}",
|
||||||
|
output.status.code(),
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linux: 尝试使用常见终端启动
|
/// macOS: iTerm2
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "macos")]
|
||||||
fn launch_linux_terminal(
|
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
|
||||||
config_file: &std::path::Path,
|
use std::process::Command;
|
||||||
config_path_escaped: &str,
|
|
||||||
|
let applescript = format!(
|
||||||
|
r#"tell application "iTerm"
|
||||||
|
activate
|
||||||
|
tell current window
|
||||||
|
create tab with default profile
|
||||||
|
tell current session
|
||||||
|
write text "bash '{}'"
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
end tell"#,
|
||||||
|
script_file.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = Command::new("osascript")
|
||||||
|
.arg("-e")
|
||||||
|
.arg(&applescript)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!(
|
||||||
|
"iTerm2 执行失败 (exit code: {:?}): {}",
|
||||||
|
output.status.code(),
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// macOS: 使用 open -a 启动支持 --args 参数的终端(Alacritty/Kitty/Ghostty)
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn launch_macos_open_app(
|
||||||
|
app_name: &str,
|
||||||
|
script_file: &std::path::Path,
|
||||||
|
use_e_flag: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let terminals = [
|
let mut cmd = Command::new("open");
|
||||||
"gnome-terminal",
|
cmd.arg("-a").arg(app_name).arg("--args");
|
||||||
"konsole",
|
|
||||||
"xfce4-terminal",
|
if use_e_flag {
|
||||||
"mate-terminal",
|
cmd.arg("-e");
|
||||||
"lxterminal",
|
}
|
||||||
"alacritty",
|
cmd.arg("bash").arg(script_file);
|
||||||
"kitty",
|
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!(
|
||||||
|
"{} 启动失败 (exit code: {:?}): {}",
|
||||||
|
app_name,
|
||||||
|
output.status.code(),
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux: 根据用户首选终端启动
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let preferred = crate::settings::get_preferred_terminal();
|
||||||
|
|
||||||
|
// Default terminal list with their arguments
|
||||||
|
let default_terminals = [
|
||||||
|
("gnome-terminal", vec!["--"]),
|
||||||
|
("konsole", vec!["-e"]),
|
||||||
|
("xfce4-terminal", vec!["-e"]),
|
||||||
|
("mate-terminal", vec!["--"]),
|
||||||
|
("lxterminal", vec!["-e"]),
|
||||||
|
("alacritty", vec!["-e"]),
|
||||||
|
("kitty", vec!["-e"]),
|
||||||
|
("ghostty", vec!["-e"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
let config_path_for_bash = config_file.to_string_lossy();
|
// Create temp script file
|
||||||
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||||
|
let config_path = config_file.to_string_lossy();
|
||||||
|
|
||||||
|
let script_content = format!(
|
||||||
|
r#"#!/bin/bash
|
||||||
|
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||||
|
echo "Using provider-specific claude config:"
|
||||||
|
echo "{config_path}"
|
||||||
|
claude --settings "{config_path}"
|
||||||
|
exec bash --norc --noprofile
|
||||||
|
"#,
|
||||||
|
config_path = config_path,
|
||||||
|
script_file = script_file.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||||
|
|
||||||
|
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||||
|
|
||||||
|
// Build terminal list: preferred terminal first (if specified), then defaults
|
||||||
|
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
|
||||||
|
// Find the preferred terminal's args from default list
|
||||||
|
let pref_args = default_terminals
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == pref.as_str())
|
||||||
|
.map(|(_, args)| args.iter().map(|s| *s).collect::<Vec<&str>>())
|
||||||
|
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
|
||||||
|
|
||||||
|
let mut list = vec![(pref.as_str(), pref_args)];
|
||||||
|
// Add remaining terminals as fallbacks
|
||||||
|
for (name, args) in &default_terminals {
|
||||||
|
if *name != pref.as_str() {
|
||||||
|
list.push((*name, args.iter().map(|s| *s).collect()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list
|
||||||
|
} else {
|
||||||
|
default_terminals
|
||||||
|
.iter()
|
||||||
|
.map(|(name, args)| (*name, args.iter().map(|s| *s).collect()))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
let mut last_error = String::from("未找到可用的终端");
|
let mut last_error = String::from("未找到可用的终端");
|
||||||
|
|
||||||
for terminal in terminals {
|
for (terminal, args) in terminals_to_try {
|
||||||
// 检查终端是否存在
|
// Check if terminal exists in common paths
|
||||||
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||||
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||||||
{
|
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists()
|
||||||
let result = match terminal {
|
|| which_command(terminal);
|
||||||
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
|
|
||||||
.arg("--")
|
if terminal_exists {
|
||||||
.arg("bash")
|
let result = Command::new(terminal)
|
||||||
.arg("-c")
|
.args(&args)
|
||||||
.arg(&shell_cmd)
|
.arg("bash")
|
||||||
.spawn(),
|
.arg(script_file.to_string_lossy().as_ref())
|
||||||
_ => Command::new(terminal)
|
.spawn();
|
||||||
.arg("-e")
|
|
||||||
.arg("bash")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(&shell_cmd)
|
|
||||||
.spawn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => return Ok(()),
|
Ok(_) => return Ok(()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
last_error = format!("启动 {} 失败: {}", terminal, e);
|
last_error = format!("执行 {} 失败: {}", terminal, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理配置文件
|
// Clean up on failure
|
||||||
|
let _ = std::fs::remove_file(&script_file);
|
||||||
let _ = std::fs::remove_file(config_file);
|
let _ = std::fs::remove_file(config_file);
|
||||||
Err(last_error)
|
Err(last_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Windows: 创建临时批处理文件启动
|
/// Check if a command exists using `which`
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn which_command(cmd: &str) -> bool {
|
||||||
|
use std::process::Command;
|
||||||
|
Command::new("which")
|
||||||
|
.arg(cmd)
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows: 根据用户首选终端启动
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn launch_windows_terminal(
|
fn launch_windows_terminal(
|
||||||
temp_dir: &std::path::Path,
|
temp_dir: &std::path::Path,
|
||||||
config_file: &std::path::Path,
|
config_file: &std::path::Path,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use std::process::Command;
|
let preferred = crate::settings::get_preferred_terminal();
|
||||||
|
let terminal = preferred.as_deref().unwrap_or("cmd");
|
||||||
|
|
||||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||||
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||||
@@ -691,21 +897,76 @@ echo {}
|
|||||||
claude --settings \"{}\"
|
claude --settings \"{}\"
|
||||||
del \"{}\" >nul 2>&1
|
del \"{}\" >nul 2>&1
|
||||||
del \"%~f0\" >nul 2>&1
|
del \"%~f0\" >nul 2>&1
|
||||||
if errorlevel 1 (
|
",
|
||||||
echo.
|
|
||||||
echo Press any key to close...
|
|
||||||
pause >nul
|
|
||||||
)",
|
|
||||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||||
|
|
||||||
Command::new("cmd")
|
let bat_path = bat_file.to_string_lossy();
|
||||||
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
|
let ps_cmd = format!("& '{}'", bat_path);
|
||||||
|
|
||||||
|
// Try the preferred terminal first
|
||||||
|
let result = match terminal {
|
||||||
|
"powershell" => run_windows_start_command(
|
||||||
|
&["powershell", "-NoExit", "-Command", &ps_cmd],
|
||||||
|
"PowerShell",
|
||||||
|
),
|
||||||
|
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
|
||||||
|
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
|
||||||
|
};
|
||||||
|
|
||||||
|
// If preferred terminal fails and it's not the default, try cmd as fallback
|
||||||
|
if result.is_err() && terminal != "cmd" {
|
||||||
|
log::warn!(
|
||||||
|
"首选终端 {} 启动失败,回退到 cmd: {:?}",
|
||||||
|
terminal,
|
||||||
|
result.as_ref().err()
|
||||||
|
);
|
||||||
|
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows: Run a start command with common error handling
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let mut full_args = vec!["/C", "start"];
|
||||||
|
full_args.extend(args);
|
||||||
|
|
||||||
|
let output = Command::new("cmd")
|
||||||
|
.args(&full_args)
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.spawn()
|
.output()
|
||||||
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
|
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!(
|
||||||
|
"{} 启动失败 (exit code: {:?}): {}",
|
||||||
|
terminal_name,
|
||||||
|
output.status.code(),
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ 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;
|
||||||
mod template;
|
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
@@ -30,8 +30,8 @@ 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::*;
|
||||||
pub use template::*;
|
|
||||||
pub use usage::*;
|
pub use usage::*;
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ 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]
|
||||||
|
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::remove_from_live_config(app_type, &id)
|
||||||
|
.map(|_| true)
|
||||||
|
.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)
|
||||||
@@ -133,6 +143,7 @@ pub async fn testUsageScript(
|
|||||||
#[allow(non_snake_case)] baseUrl: Option<String>,
|
#[allow(non_snake_case)] baseUrl: Option<String>,
|
||||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||||
#[allow(non_snake_case)] userId: Option<String>,
|
#[allow(non_snake_case)] userId: Option<String>,
|
||||||
|
#[allow(non_snake_case)] templateType: Option<String>,
|
||||||
) -> Result<crate::provider::UsageResult, String> {
|
) -> Result<crate::provider::UsageResult, 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::test_usage_script(
|
ProviderService::test_usage_script(
|
||||||
@@ -145,6 +156,7 @@ pub async fn testUsageScript(
|
|||||||
baseUrl.as_deref(),
|
baseUrl.as_deref(),
|
||||||
accessToken.as_deref(),
|
accessToken.as_deref(),
|
||||||
userId.as_deref(),
|
userId.as_deref(),
|
||||||
|
templateType.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
@@ -323,3 +335,27 @@ pub fn sync_universal_provider(
|
|||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenCode 专属命令
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 从 OpenCode live 配置导入供应商到数据库
|
||||||
|
///
|
||||||
|
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
|
||||||
|
/// 用户可能已经在 opencode.json 中配置了供应商。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
|
crate::services::provider::import_opencode_providers_from_live(state.inner())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 OpenCode live 配置中的供应商 ID 列表
|
||||||
|
///
|
||||||
|
/// 用于前端判断供应商是否已添加到 opencode.json
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||||
|
crate::opencode_config::get_providers()
|
||||||
|
.map(|providers| providers.keys().cloned().collect())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -80,3 +80,30 @@ pub async fn set_rectifier_config(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取日志配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_log_config(
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> Result<crate::proxy::types::LogConfig, String> {
|
||||||
|
state.db.get_log_config().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置日志配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_log_config(
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
config: crate::proxy::types::LogConfig,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_log_config(&config)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
log::set_max_level(config.to_level_filter());
|
||||||
|
log::info!(
|
||||||
|
"日志配置已更新: enabled={}, level={}",
|
||||||
|
config.enabled,
|
||||||
|
config.level
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
|
|||||||
"claude" => Ok(AppType::Claude),
|
"claude" => Ok(AppType::Claude),
|
||||||
"codex" => Ok(AppType::Codex),
|
"codex" => Ok(AppType::Codex),
|
||||||
"gemini" => Ok(AppType::Gemini),
|
"gemini" => Ok(AppType::Gemini),
|
||||||
|
"opencode" => Ok(AppType::OpenCode),
|
||||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::database::lock_conn;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::{
|
|
||||||
BatchInstallResult, ComponentDetail, InstalledComponent, PaginatedResult, TemplateComponent,
|
|
||||||
TemplateRepo, TemplateService,
|
|
||||||
};
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 刷新模板索引
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn refresh_template_index(state: State<'_, AppState>) -> Result<(), String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
// 使用 spawn_blocking 在后台线程中执行数据库操作
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.refresh_index(&conn)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取模板组件列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_template_components(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
component_type: Option<String>,
|
|
||||||
category: Option<String>,
|
|
||||||
search: Option<String>,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
app_type: Option<String>,
|
|
||||||
) -> Result<PaginatedResult<TemplateComponent>, AppError> {
|
|
||||||
let (mut components, total) = state.db.list_components(
|
|
||||||
component_type.as_deref(),
|
|
||||||
category.as_deref(),
|
|
||||||
search.as_deref(),
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 填充 installed 字段
|
|
||||||
if let Some(app) = &app_type {
|
|
||||||
let installed_ids = state.db.get_installed_component_ids(app)?;
|
|
||||||
for component in &mut components {
|
|
||||||
if let Some(id) = component.id {
|
|
||||||
component.installed = installed_ids.contains(&id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(PaginatedResult {
|
|
||||||
items: components,
|
|
||||||
total: total as i64,
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取组件详情
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_template_component(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
) -> Result<ComponentDetail, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
let detail = tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.get_component(&conn, id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装组件
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn install_template_component(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.install_component(&conn, id, &app_type)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 卸载组件
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn uninstall_template_component(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let service = TemplateService::new().map_err(|e| AppError::Config(e.to_string()))?;
|
|
||||||
let conn = lock_conn!(state.db.conn);
|
|
||||||
|
|
||||||
service
|
|
||||||
.uninstall_component(&conn, id, &app_type)
|
|
||||||
.map_err(|e| AppError::Config(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量安装组件
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn batch_install_template_components(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
ids: Vec<i64>,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<BatchInstallResult, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
let result = tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.batch_install(&conn, ids, &app_type)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取模板仓库列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_template_repos(state: State<'_, AppState>) -> Result<Vec<TemplateRepo>, AppError> {
|
|
||||||
state.db.list_repos()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加模板仓库
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn add_template_repo(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
owner: String,
|
|
||||||
name: String,
|
|
||||||
branch: String,
|
|
||||||
) -> Result<i64, AppError> {
|
|
||||||
let repo = TemplateRepo::new(owner, name, branch);
|
|
||||||
state.db.insert_repo(&repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模板仓库
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn remove_template_repo(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
|
|
||||||
state.db.delete_repo(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换仓库启用状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn toggle_template_repo(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
state.db.toggle_repo_enabled(id, enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取组件分类列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_template_categories(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
component_type: Option<String>,
|
|
||||||
) -> Result<Vec<String>, AppError> {
|
|
||||||
let conn = lock_conn!(state.db.conn);
|
|
||||||
|
|
||||||
// 构建查询语句
|
|
||||||
let sql = if let Some(ct) = component_type {
|
|
||||||
format!(
|
|
||||||
"SELECT DISTINCT category FROM template_components WHERE component_type = '{ct}' AND category IS NOT NULL ORDER BY category"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
"SELECT DISTINCT category FROM template_components WHERE category IS NOT NULL ORDER BY category".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
|
||||||
let categories = stmt
|
|
||||||
.query_map([], |row| row.get::<_, String>(0))?
|
|
||||||
.collect::<Result<Vec<String>, _>>()?;
|
|
||||||
|
|
||||||
Ok(categories)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取已安装组件列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_installed_components(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app_type: Option<String>,
|
|
||||||
component_type: Option<String>,
|
|
||||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.list_installed_components(app_type.as_deref(), component_type.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 预览组件内容
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn preview_component_content(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.preview_content(&conn, id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取市场组合列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_marketplace_bundles(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<Vec<crate::services::MarketplaceBundle>, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.fetch_marketplace_bundles(&conn)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
|
||||||
}
|
|
||||||
+48
-4
@@ -6,7 +6,26 @@ use std::path::{Path, PathBuf};
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// 获取用户主目录,带回退和日志
|
/// 获取用户主目录,带回退和日志
|
||||||
fn get_home_dir() -> PathBuf {
|
///
|
||||||
|
/// ## Windows 注意事项
|
||||||
|
///
|
||||||
|
/// - `dirs::home_dir()` 在 Windows 上使用 `SHGetKnownFolderPath(FOLDERID_Profile)`,
|
||||||
|
/// 返回的是真实用户目录(类似 `C:\\Users\\Alice`),与 v3.10.2 行为一致。
|
||||||
|
/// - 不要直接使用 `HOME` 环境变量:它可能由 Git/Cygwin/MSYS 等第三方工具注入,
|
||||||
|
/// 且不一定等于用户目录,可能导致 `.cc-switch/cc-switch.db` 路径变化,从而“看起来像数据丢失”。
|
||||||
|
///
|
||||||
|
/// ## 测试隔离
|
||||||
|
///
|
||||||
|
/// 为了让 Windows CI/本地测试能稳定隔离真实用户数据,可通过 `CC_SWITCH_TEST_HOME`
|
||||||
|
/// 显式覆盖 home dir(仅用于测试/调试场景)。
|
||||||
|
pub fn get_home_dir() -> PathBuf {
|
||||||
|
if let Ok(home) = std::env::var("CC_SWITCH_TEST_HOME") {
|
||||||
|
let trimmed = home.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return PathBuf::from(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dirs::home_dir().unwrap_or_else(|| {
|
dirs::home_dir().unwrap_or_else(|| {
|
||||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||||
PathBuf::from(".")
|
PathBuf::from(".")
|
||||||
@@ -72,9 +91,34 @@ pub fn get_app_config_dir() -> PathBuf {
|
|||||||
return custom;
|
return custom;
|
||||||
}
|
}
|
||||||
|
|
||||||
dirs::home_dir()
|
let default_dir = get_home_dir().join(".cc-switch");
|
||||||
.expect("无法获取用户主目录")
|
|
||||||
.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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取应用配置文件路径
|
/// 获取应用配置文件路径
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ impl Database {
|
|||||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
|
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||||
FROM mcp_servers
|
FROM mcp_servers
|
||||||
ORDER BY name ASC, id ASC"
|
ORDER BY name ASC, id ASC"
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -30,6 +30,7 @@ impl Database {
|
|||||||
let enabled_claude: bool = row.get(7)?;
|
let enabled_claude: bool = row.get(7)?;
|
||||||
let enabled_codex: bool = row.get(8)?;
|
let enabled_codex: bool = row.get(8)?;
|
||||||
let enabled_gemini: bool = row.get(9)?;
|
let enabled_gemini: bool = row.get(9)?;
|
||||||
|
let enabled_opencode: bool = row.get(10)?;
|
||||||
|
|
||||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||||
@@ -44,6 +45,7 @@ impl Database {
|
|||||||
claude: enabled_claude,
|
claude: enabled_claude,
|
||||||
codex: enabled_codex,
|
codex: enabled_codex,
|
||||||
gemini: enabled_gemini,
|
gemini: enabled_gemini,
|
||||||
|
opencode: enabled_opencode,
|
||||||
},
|
},
|
||||||
description,
|
description,
|
||||||
homepage,
|
homepage,
|
||||||
@@ -68,8 +70,8 @@ impl Database {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO mcp_servers (
|
"INSERT OR REPLACE INTO mcp_servers (
|
||||||
id, name, server_config, description, homepage, docs, tags,
|
id, name, server_config, description, homepage, docs, tags,
|
||||||
enabled_claude, enabled_codex, enabled_gemini
|
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
params![
|
params![
|
||||||
server.id,
|
server.id,
|
||||||
server.name,
|
server.name,
|
||||||
@@ -84,6 +86,7 @@ impl Database {
|
|||||||
server.apps.claude,
|
server.apps.claude,
|
||||||
server.apps.codex,
|
server.apps.codex,
|
||||||
server.apps.gemini,
|
server.apps.gemini,
|
||||||
|
server.apps.opencode,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pub mod proxy;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod stream_check;
|
pub mod stream_check;
|
||||||
pub mod template;
|
|
||||||
pub mod universal_providers;
|
pub mod universal_providers;
|
||||||
|
|
||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
@@ -614,4 +799,118 @@ impl Database {
|
|||||||
log::info!("已删除所有 Live 配置备份");
|
log::info!("已删除所有 Live 配置备份");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Sync Methods for Tray Menu ====================
|
||||||
|
|
||||||
|
/// 同步获取应用的 proxy 启用状态和自动故障转移状态
|
||||||
|
///
|
||||||
|
/// 用于托盘菜单构建等同步场景
|
||||||
|
/// 返回 (enabled, auto_failover_enabled)
|
||||||
|
pub fn get_proxy_flags_sync(&self, app_type: &str) -> (bool, bool) {
|
||||||
|
let conn = match self.conn.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return (false, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT enabled, auto_failover_enabled FROM proxy_config WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
|row| Ok((row.get::<_, i32>(0)? != 0, row.get::<_, i32>(1)? != 0)),
|
||||||
|
)
|
||||||
|
.unwrap_or((false, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步设置应用的 proxy 启用状态和自动故障转移状态
|
||||||
|
///
|
||||||
|
/// 用于托盘菜单点击等同步场景
|
||||||
|
pub fn set_proxy_flags_sync(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
enabled: bool,
|
||||||
|
auto_failover_enabled: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Database(format!("Mutex lock failed: {e}")))?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET enabled = ?2, auto_failover_enabled = ?3, updated_at = datetime('now') WHERE app_type = ?1",
|
||||||
|
rusqlite::params![
|
||||||
|
app_type,
|
||||||
|
if enabled { 1 } else { 0 },
|
||||||
|
if auto_failover_enabled { 1 } else { 0 },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,4 +186,22 @@ impl Database {
|
|||||||
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
|
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
|
||||||
self.set_setting("rectifier_config", &json)
|
self.set_setting("rectifier_config", &json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 日志配置 ---
|
||||||
|
|
||||||
|
/// 获取日志配置
|
||||||
|
pub fn get_log_config(&self) -> Result<crate::proxy::types::LogConfig, AppError> {
|
||||||
|
match self.get_setting("log_config")? {
|
||||||
|
Some(json) => serde_json::from_str(&json)
|
||||||
|
.map_err(|e| AppError::Database(format!("解析日志配置失败: {e}"))),
|
||||||
|
None => Ok(crate::proxy::types::LogConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新日志配置
|
||||||
|
pub fn set_log_config(&self, config: &crate::proxy::types::LogConfig) -> Result<(), AppError> {
|
||||||
|
let json = serde_json::to_string(config)
|
||||||
|
.map_err(|e| AppError::Database(format!("序列化日志配置失败: {e}")))?;
|
||||||
|
self.set_setting("log_config", &json)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
||||||
//!
|
//!
|
||||||
//! v3.10.0+ 统一管理架构:
|
//! v3.10.0+ 统一管理架构:
|
||||||
//! - Skills 使用统一的 id 主键,支持三应用启用标志
|
//! - Skills 使用统一的 id 主键,支持四应用启用标志
|
||||||
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
||||||
|
|
||||||
use crate::app_config::{InstalledSkill, SkillApps};
|
use crate::app_config::{InstalledSkill, SkillApps};
|
||||||
@@ -22,7 +22,7 @@ impl Database {
|
|||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||||
FROM skills ORDER BY name ASC",
|
FROM skills ORDER BY name ASC",
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -42,8 +42,9 @@ impl Database {
|
|||||||
claude: row.get(8)?,
|
claude: row.get(8)?,
|
||||||
codex: row.get(9)?,
|
codex: row.get(9)?,
|
||||||
gemini: row.get(10)?,
|
gemini: row.get(10)?,
|
||||||
|
opencode: row.get(11)?,
|
||||||
},
|
},
|
||||||
installed_at: row.get(11)?,
|
installed_at: row.get(12)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -62,7 +63,7 @@ impl Database {
|
|||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||||
FROM skills WHERE id = ?1",
|
FROM skills WHERE id = ?1",
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -81,8 +82,9 @@ impl Database {
|
|||||||
claude: row.get(8)?,
|
claude: row.get(8)?,
|
||||||
codex: row.get(9)?,
|
codex: row.get(9)?,
|
||||||
gemini: row.get(10)?,
|
gemini: row.get(10)?,
|
||||||
|
opencode: row.get(11)?,
|
||||||
},
|
},
|
||||||
installed_at: row.get(11)?,
|
installed_at: row.get(12)?,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,8 +101,8 @@ impl Database {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO skills
|
"INSERT OR REPLACE INTO skills
|
||||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
|
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
params![
|
params![
|
||||||
skill.id,
|
skill.id,
|
||||||
skill.name,
|
skill.name,
|
||||||
@@ -113,6 +115,7 @@ impl Database {
|
|||||||
skill.apps.claude,
|
skill.apps.claude,
|
||||||
skill.apps.codex,
|
skill.apps.codex,
|
||||||
skill.apps.gemini,
|
skill.apps.gemini,
|
||||||
|
skill.apps.opencode,
|
||||||
skill.installed_at,
|
skill.installed_at,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -142,8 +145,8 @@ impl Database {
|
|||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let affected = conn
|
let affected = conn
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
|
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
|
||||||
params![apps.claude, apps.codex, apps.gemini, id],
|
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(affected > 0)
|
Ok(affected > 0)
|
||||||
@@ -200,24 +203,32 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 初始化默认的 Skill 仓库(首次启动时调用)
|
/// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库)
|
||||||
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
||||||
// 检查是否已有仓库
|
// 获取已有仓库列表
|
||||||
let existing = self.get_skill_repos()?;
|
let existing = self.get_skill_repos()?;
|
||||||
if !existing.is_empty() {
|
let existing_keys: std::collections::HashSet<(String, String)> = existing
|
||||||
return Ok(0);
|
.iter()
|
||||||
}
|
.map(|r| (r.owner.clone(), r.name.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// 获取默认仓库列表
|
// 获取默认仓库列表
|
||||||
let default_store = crate::services::skill::SkillStore::default();
|
let default_store = crate::services::skill::SkillStore::default();
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
|
|
||||||
|
// 仅插入缺失的默认仓库
|
||||||
for repo in &default_store.repos {
|
for repo in &default_store.repos {
|
||||||
self.save_skill_repo(repo)?;
|
let key = (repo.owner.clone(), repo.name.clone());
|
||||||
count += 1;
|
if !existing_keys.contains(&key) {
|
||||||
|
self.save_skill_repo(repo)?;
|
||||||
|
count += 1;
|
||||||
|
log::info!("补充默认 Skill 仓库: {}/{}", repo.owner, repo.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
|
if count > 0 {
|
||||||
|
log::info!("补充默认 Skill 仓库完成,新增 {count} 个");
|
||||||
|
}
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
//! Template 数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供 Template Repos、Template Components 和 Installed Components 的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::template::{
|
|
||||||
ComponentType, InstalledComponent, TemplateComponent, TemplateRepo,
|
|
||||||
};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use rusqlite::{params, OptionalExtension};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
// ==================== TemplateRepo 相关 ====================
|
|
||||||
|
|
||||||
/// 插入模板仓库
|
|
||||||
pub fn insert_repo(&self, repo: &TemplateRepo) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_repos (owner, name, branch, enabled, created_at, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
params![repo.owner, repo.name, repo.branch, repo.enabled, now, now],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个模板仓库
|
|
||||||
pub fn get_repo(&self, id: i64) -> Result<Option<TemplateRepo>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
WHERE id = ?1",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
let repo = stmt
|
|
||||||
.query_row(params![id], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get(4)?,
|
|
||||||
created_at: row
|
|
||||||
.get::<_, String>(5)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
updated_at: row
|
|
||||||
.get::<_, String>(6)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.map_err(|e| AppError::Database(format!("查询模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取所有模板仓库
|
|
||||||
pub fn list_repos(&self) -> Result<Vec<TemplateRepo>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库列表失败: {e}")))?;
|
|
||||||
|
|
||||||
let repo_iter = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get(4)?,
|
|
||||||
created_at: row
|
|
||||||
.get::<_, String>(5)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
updated_at: row
|
|
||||||
.get::<_, String>(6)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询模板仓库列表失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut repos = Vec::new();
|
|
||||||
for repo_res in repo_iter {
|
|
||||||
repos.push(repo_res.map_err(|e| AppError::Database(format!("解析模板仓库失败: {e}")))?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新模板仓库
|
|
||||||
pub fn update_repo(&self, repo: &TemplateRepo) -> Result<(), AppError> {
|
|
||||||
let repo_id = repo
|
|
||||||
.id
|
|
||||||
.ok_or_else(|| AppError::Database("仓库 ID 不能为空".to_string()))?;
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos
|
|
||||||
SET owner = ?1, name = ?2, branch = ?3, enabled = ?4, updated_at = ?5
|
|
||||||
WHERE id = ?6",
|
|
||||||
params![
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
repo.branch,
|
|
||||||
repo.enabled,
|
|
||||||
now,
|
|
||||||
repo_id
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("更新模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模板仓库
|
|
||||||
pub fn delete_repo(&self, id: i64) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
|
||||||
.map_err(|e| AppError::Database(format!("删除模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换仓库启用状态
|
|
||||||
pub fn toggle_repo_enabled(&self, id: i64, enabled: bool) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos SET enabled = ?1, updated_at = ?2 WHERE id = ?3",
|
|
||||||
params![enabled, now, id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("切换仓库启用状态失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== TemplateComponent 相关 ====================
|
|
||||||
|
|
||||||
/// 插入模板组件
|
|
||||||
pub fn insert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_components
|
|
||||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
component.category,
|
|
||||||
component.name,
|
|
||||||
component.path,
|
|
||||||
component.description,
|
|
||||||
component.content_hash,
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个模板组件
|
|
||||||
pub fn get_component(&self, id: i64) -> Result<Option<TemplateComponent>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let component = stmt
|
|
||||||
.query_row(params![id], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false, // 需要单独查询
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.map_err(|e| AppError::Database(format!("查询模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(component)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取组件列表(支持过滤和分页)
|
|
||||||
pub fn list_components(
|
|
||||||
&self,
|
|
||||||
component_type: Option<&str>,
|
|
||||||
category: Option<&str>,
|
|
||||||
search: Option<&str>,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
) -> Result<(Vec<TemplateComponent>, u32), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 构建 WHERE 子句
|
|
||||||
let mut where_clauses = Vec::new();
|
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(ct) = component_type {
|
|
||||||
where_clauses.push("component_type = ?");
|
|
||||||
params_vec.push(Box::new(ct.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cat) = category {
|
|
||||||
where_clauses.push("category = ?");
|
|
||||||
params_vec.push(Box::new(cat.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(s) = search {
|
|
||||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
|
||||||
let pattern = format!("%{s}%");
|
|
||||||
params_vec.push(Box::new(pattern.clone()));
|
|
||||||
params_vec.push(Box::new(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_sql = if where_clauses.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", where_clauses.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查询总数
|
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
|
||||||
|
|
||||||
let total: u32 = {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&count_sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备统计组件数量失败: {e}")))?;
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
stmt.query_row(¶ms_refs[..], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(format!("统计组件数量失败: {e}")))?
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查询数据
|
|
||||||
let offset = (page.saturating_sub(1)) * page_size;
|
|
||||||
let query_sql = format!(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY name ASC
|
|
||||||
LIMIT ? OFFSET ?"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&query_sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询组件列表失败: {e}")))?;
|
|
||||||
|
|
||||||
params_vec.push(Box::new(page_size));
|
|
||||||
params_vec.push(Box::new(offset));
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let component_iter = stmt
|
|
||||||
.query_map(¶ms_refs[..], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false, // 需要单独查询
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询组件列表失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut components = Vec::new();
|
|
||||||
for component_res in component_iter {
|
|
||||||
components
|
|
||||||
.push(component_res.map_err(|e| AppError::Database(format!("解析组件失败: {e}")))?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((components, total))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除仓库的所有组件
|
|
||||||
pub fn delete_components_by_repo(&self, repo_id: i64) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM template_components WHERE repo_id = ?1",
|
|
||||||
params![repo_id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("删除仓库组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upsert 模板组件(根据 repo_id + component_type + path 判断是否已存在)
|
|
||||||
pub fn upsert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_components
|
|
||||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
||||||
ON CONFLICT(repo_id, component_type, path) DO UPDATE SET
|
|
||||||
category = excluded.category,
|
|
||||||
name = excluded.name,
|
|
||||||
description = excluded.description,
|
|
||||||
content_hash = excluded.content_hash,
|
|
||||||
updated_at = excluded.updated_at",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
component.category,
|
|
||||||
component.name,
|
|
||||||
component.path,
|
|
||||||
component.description,
|
|
||||||
component.content_hash,
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Upsert 模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== InstalledComponent 相关 ====================
|
|
||||||
|
|
||||||
/// 插入已安装组件
|
|
||||||
pub fn insert_installed(&self, installed: &InstalledComponent) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let installed_at = installed.installed_at.to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO installed_components
|
|
||||||
(component_id, component_type, name, path, app_type, installed_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
params![
|
|
||||||
installed.component_id,
|
|
||||||
installed.component_type.as_str(),
|
|
||||||
installed.name,
|
|
||||||
installed.path,
|
|
||||||
installed.app_type,
|
|
||||||
installed_at
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除已安装组件
|
|
||||||
pub fn delete_installed(
|
|
||||||
&self,
|
|
||||||
component_type: &str,
|
|
||||||
path: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM installed_components
|
|
||||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
|
||||||
params![component_type, path, app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("删除已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取已安装组件列表
|
|
||||||
pub fn list_installed(
|
|
||||||
&self,
|
|
||||||
app_type: Option<&str>,
|
|
||||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type
|
|
||||||
{
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
WHERE app_type = ?
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![Box::new(at.to_string())],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let installed_iter = stmt
|
|
||||||
.query_map(¶ms_refs[..], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
let installed_at_str: String = row.get(6)?;
|
|
||||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(InstalledComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
component_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
name: row.get(3)?,
|
|
||||||
path: row.get(4)?,
|
|
||||||
app_type: row.get(5)?,
|
|
||||||
installed_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut installed = Vec::new();
|
|
||||||
for installed_res in installed_iter {
|
|
||||||
installed.push(
|
|
||||||
installed_res
|
|
||||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(installed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取已安装组件列表(支持 app_type 和 component_type 过滤)
|
|
||||||
pub fn list_installed_components(
|
|
||||||
&self,
|
|
||||||
app_type: Option<&str>,
|
|
||||||
component_type: Option<&str>,
|
|
||||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 构建 WHERE 子句
|
|
||||||
let mut where_clauses = Vec::new();
|
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(at) = app_type {
|
|
||||||
where_clauses.push("app_type = ?");
|
|
||||||
params_vec.push(Box::new(at.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ct) = component_type {
|
|
||||||
where_clauses.push("component_type = ?");
|
|
||||||
params_vec.push(Box::new(ct.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_sql = if where_clauses.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", where_clauses.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let installed_iter = stmt
|
|
||||||
.query_map(¶ms_refs[..], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
let installed_at_str: String = row.get(6)?;
|
|
||||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(InstalledComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
component_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
name: row.get(3)?,
|
|
||||||
path: row.get(4)?,
|
|
||||||
app_type: row.get(5)?,
|
|
||||||
installed_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut installed = Vec::new();
|
|
||||||
for installed_res in installed_iter {
|
|
||||||
installed.push(
|
|
||||||
installed_res
|
|
||||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(installed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查组件是否已安装
|
|
||||||
pub fn is_installed(
|
|
||||||
&self,
|
|
||||||
component_type: &str,
|
|
||||||
path: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM installed_components
|
|
||||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
|
||||||
params![component_type, path, app_type],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("检查组件安装状态失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取指定应用已安装的组件 ID 列表
|
|
||||||
pub fn get_installed_component_ids(&self, app_type: &str) -> Result<Vec<i64>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT component_id FROM installed_components WHERE app_type = ?1 AND component_id IS NOT NULL",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let ids: Vec<i64> = stmt
|
|
||||||
.query_map(params![app_type], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
|||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 3;
|
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> {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ impl Database {
|
|||||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -85,6 +85,7 @@ impl Database {
|
|||||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0
|
installed_at INTEGER NOT NULL DEFAULT 0
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
@@ -119,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()))?;
|
||||||
|
|
||||||
@@ -169,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',
|
||||||
@@ -302,90 +306,6 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 15. Template Repos 表 (模板仓库)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 插入默认模板仓库
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
|
|
||||||
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 16. Template Components 表 (模板组件)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
repo_id INTEGER NOT NULL,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
category TEXT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
content_hash TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(repo_id, component_type, path)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 为 template_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_type
|
|
||||||
ON template_components(component_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_category
|
|
||||||
ON template_components(category)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 17. Installed Components 表 (已安装组件)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS installed_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
component_id INTEGER,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(component_type, path, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 为 installed_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
|
|
||||||
ON installed_components(app_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,12 +350,16 @@ impl Database {
|
|||||||
Self::migrate_v2_to_v3(conn)?;
|
Self::migrate_v2_to_v3(conn)?;
|
||||||
Self::set_user_version(conn, 3)?;
|
Self::set_user_version(conn, 3)?;
|
||||||
}
|
}
|
||||||
// v3 -> v4: Claude Code Templates 市场功能(暂未启用)
|
3 => {
|
||||||
// 3 => {
|
log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)");
|
||||||
// log::info!("迁移数据库从 v3 到 v4(Claude Code Templates 市场功能)");
|
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}"
|
||||||
@@ -605,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',
|
||||||
@@ -761,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'))
|
||||||
)", [])?;
|
)", [])?;
|
||||||
|
|
||||||
@@ -876,97 +803,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v3 -> v4 迁移:添加 Claude Code Templates 功能相关表
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
// 1. template_repos 表 - 存储模板仓库信息
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 template_repos 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 2. template_components 表 - 存储从仓库中发现的模板组件
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
repo_id INTEGER NOT NULL,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
category TEXT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
content_hash TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(repo_id, component_type, path)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 template_components 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 为 template_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_type
|
|
||||||
ON template_components(component_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 template_components 类型索引失败: {e}")))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_category
|
|
||||||
ON template_components(category)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 template_components 分类索引失败: {e}")))?;
|
|
||||||
|
|
||||||
// 3. installed_components 表 - 存储已安装的组件
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS installed_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
component_id INTEGER,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(component_type, path, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 installed_components 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 为 installed_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
|
|
||||||
ON installed_components(app_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 installed_components 应用索引失败: {e}")))?;
|
|
||||||
|
|
||||||
// 4. 插入默认模板仓库
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
|
|
||||||
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入默认模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
log::info!("已创建 Claude Code Templates 相关表并插入默认仓库");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v2 -> v3 迁移:Skills 统一管理架构
|
/// v2 -> v3 迁移:Skills 统一管理架构
|
||||||
///
|
///
|
||||||
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
||||||
@@ -1030,6 +866,54 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v3 -> v4 迁移:添加 OpenCode 支持
|
||||||
|
///
|
||||||
|
/// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。
|
||||||
|
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
// 为 mcp_servers 表添加 enabled_opencode 列
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"mcp_servers",
|
||||||
|
"enabled_opencode",
|
||||||
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 为 skills 表添加 enabled_opencode 列
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"skills",
|
||||||
|
"enabled_opencode",
|
||||||
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
|
||||||
|
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 返回的模型名称标准化后一致
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -518,7 +578,8 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
count > 0,
|
count > 0,
|
||||||
"模型定价数据应该在初始化时自动填充,实际数量: {count}"
|
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
||||||
|
count
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证包含 Claude 模型
|
// 验证包含 Claude 模型
|
||||||
@@ -531,7 +592,8 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
.expect("check claude");
|
.expect("check claude");
|
||||||
assert!(
|
assert!(
|
||||||
claude_count > 0,
|
claude_count > 0,
|
||||||
"应该包含 Claude 模型定价,实际数量: {claude_count}"
|
"应该包含 Claude 模型定价,实际数量: {}",
|
||||||
|
claude_count
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证包含 GPT 模型
|
// 验证包含 GPT 模型
|
||||||
@@ -544,7 +606,8 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
.expect("check gpt");
|
.expect("check gpt");
|
||||||
assert!(
|
assert!(
|
||||||
gpt_count > 0,
|
gpt_count > 0,
|
||||||
"应该包含 GPT 模型定价,实际数量: {gpt_count}"
|
"应该包含 GPT 模型定价,实际数量: {}",
|
||||||
|
gpt_count
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证包含 Gemini 模型
|
// 验证包含 Gemini 模型
|
||||||
@@ -557,90 +620,7 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
.expect("check gemini");
|
.expect("check gemini");
|
||||||
assert!(
|
assert!(
|
||||||
gemini_count > 0,
|
gemini_count > 0,
|
||||||
"应该包含 Gemini 模型定价,实际数量: {gemini_count}"
|
"应该包含 Gemini 模型定价,实际数量: {}",
|
||||||
|
gemini_count
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v2_to_v3_migration_creates_template_tables() {
|
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
|
||||||
|
|
||||||
// 创建 v2 schema(即当前完整的 schema)
|
|
||||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
|
||||||
Database::set_user_version(&conn, 2).expect("set v2 version");
|
|
||||||
|
|
||||||
// 应用迁移到 v3
|
|
||||||
Database::apply_schema_migrations_on_conn(&conn).expect("migrate to v3");
|
|
||||||
|
|
||||||
// 验证版本号已更新
|
|
||||||
assert_eq!(
|
|
||||||
Database::get_user_version(&conn).expect("read version"),
|
|
||||||
3,
|
|
||||||
"版本应该更新为 3"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证 template_repos 表存在且包含默认仓库
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM template_repos", [], |row| row.get(0))
|
|
||||||
.expect("count template_repos");
|
|
||||||
assert_eq!(count, 1, "应该有 1 个默认模板仓库");
|
|
||||||
|
|
||||||
let (owner, name, branch): (String, String, String) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT owner, name, branch FROM template_repos WHERE id = 1",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)
|
|
||||||
.expect("read default repo");
|
|
||||||
assert_eq!(owner, "yovinchen", "默认仓库 owner 应该是 yovinchen");
|
|
||||||
assert_eq!(
|
|
||||||
name, "claude-code-templates",
|
|
||||||
"默认仓库 name 应该是 claude-code-templates"
|
|
||||||
);
|
|
||||||
assert_eq!(branch, "main", "默认仓库 branch 应该是 main");
|
|
||||||
|
|
||||||
// 验证 template_components 表存在
|
|
||||||
assert!(
|
|
||||||
Database::table_exists(&conn, "template_components").expect("check table"),
|
|
||||||
"template_components 表应该存在"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证 installed_components 表存在
|
|
||||||
assert!(
|
|
||||||
Database::table_exists(&conn, "installed_components").expect("check table"),
|
|
||||||
"installed_components 表应该存在"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证索引存在
|
|
||||||
let index_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND (
|
|
||||||
name = 'idx_template_components_type' OR
|
|
||||||
name = 'idx_template_components_category' OR
|
|
||||||
name = 'idx_installed_components_app'
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("count indexes");
|
|
||||||
assert_eq!(index_count, 3, "应该创建 3 个索引");
|
|
||||||
|
|
||||||
// 验证外键约束
|
|
||||||
let fk_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('template_components')",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("count fk");
|
|
||||||
assert_eq!(fk_count, 1, "template_components 应该有 1 个外键约束");
|
|
||||||
|
|
||||||
let fk_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('installed_components')",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("count fk");
|
|
||||||
assert_eq!(fk_count, 1, "installed_components 应该有 1 个外键约束");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for app in apps_str.split(',') {
|
for app in apps_str.split(',') {
|
||||||
@@ -173,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
|||||||
"claude" => apps.claude = true,
|
"claude" => apps.claude = true,
|
||||||
"codex" => apps.codex = true,
|
"codex" => apps.codex = true,
|
||||||
"gemini" => apps.gemini = true,
|
"gemini" => apps.gemini = true,
|
||||||
|
"opencode" => apps.opencode = true,
|
||||||
other => {
|
other => {
|
||||||
return Err(AppError::InvalidInput(format!(
|
return Err(AppError::InvalidInput(format!(
|
||||||
"Invalid app in 'apps': {other}"
|
"Invalid app in 'apps': {other}"
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request(
|
|||||||
AppType::Claude => build_claude_settings(request),
|
AppType::Claude => build_claude_settings(request),
|
||||||
AppType::Codex => build_codex_settings(request),
|
AppType::Codex => build_codex_settings(request),
|
||||||
AppType::Gemini => build_gemini_settings(request),
|
AppType::Gemini => build_gemini_settings(request),
|
||||||
|
AppType::OpenCode => build_opencode_settings(request),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build usage script configuration if provided
|
// Build usage script configuration if provided
|
||||||
@@ -225,6 +226,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
}),
|
}),
|
||||||
access_token: request.usage_access_token.clone(),
|
access_token: request.usage_access_token.clone(),
|
||||||
user_id: request.usage_user_id.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,
|
auto_query_interval: request.usage_auto_interval,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,6 +364,33 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|||||||
json!({ "env": env })
|
json!({ "env": env })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build OpenCode settings configuration
|
||||||
|
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||||
|
let endpoint = get_primary_endpoint(request);
|
||||||
|
|
||||||
|
// Build options object
|
||||||
|
let mut options = serde_json::Map::new();
|
||||||
|
if !endpoint.is_empty() {
|
||||||
|
options.insert("baseURL".to_string(), json!(endpoint));
|
||||||
|
}
|
||||||
|
if let Some(api_key) = &request.api_key {
|
||||||
|
options.insert("apiKey".to_string(), json!(api_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build models object
|
||||||
|
let mut models = serde_json::Map::new();
|
||||||
|
if let Some(model) = &request.model {
|
||||||
|
models.insert(model.clone(), json!({ "name": model }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to openai-compatible npm package
|
||||||
|
json!({
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"options": options,
|
||||||
|
"models": models
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Config Merge Logic
|
// Config Merge Logic
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -365,7 +365,8 @@ fn test_parse_prompt_deeplink() {
|
|||||||
let content = "Hello World";
|
let content = "Hello World";
|
||||||
let content_b64 = BASE64_STANDARD.encode(content);
|
let content_b64 = BASE64_STANDARD.encode(content);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={content_b64}&description=desc&enabled=true"
|
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true",
|
||||||
|
content_b64
|
||||||
);
|
);
|
||||||
|
|
||||||
let request = parse_deeplink_url(&url).unwrap();
|
let request = parse_deeplink_url(&url).unwrap();
|
||||||
@@ -382,7 +383,8 @@ fn test_parse_mcp_deeplink() {
|
|||||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||||
let config_b64 = BASE64_STANDARD.encode(config);
|
let config_b64 = BASE64_STANDARD.encode(config);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={config_b64}&enabled=true"
|
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true",
|
||||||
|
config_b64
|
||||||
);
|
);
|
||||||
|
|
||||||
let request = parse_deeplink_url(&url).unwrap();
|
let request = parse_deeplink_url(&url).unwrap();
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
use crate::config::write_text_file;
|
use crate::config::{get_home_dir, write_text_file};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// 获取用户主目录,带回退和日志
|
|
||||||
fn get_home_dir() -> PathBuf {
|
|
||||||
dirs::home_dir().unwrap_or_else(|| {
|
|
||||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
|
||||||
PathBuf::from(".")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||||
pub fn get_gemini_dir() -> PathBuf {
|
pub fn get_gemini_dir() -> PathBuf {
|
||||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+88
-26
@@ -13,6 +13,7 @@ mod gemini_config;
|
|||||||
mod gemini_mcp;
|
mod gemini_mcp;
|
||||||
mod init_status;
|
mod init_status;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod opencode_config;
|
||||||
mod panic_hook;
|
mod panic_hook;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod prompt_files;
|
mod prompt_files;
|
||||||
@@ -20,6 +21,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;
|
||||||
@@ -265,33 +267,41 @@ pub fn run() {
|
|||||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 初始化日志(Debug 和 Release 模式都启用 Info 级别)
|
// 初始化日志(单文件输出到 <app_config_dir>/logs/cc-switch.log)
|
||||||
// 日志同时输出到控制台和文件(<app_config_dir>/logs/;若设置了覆盖则使用覆盖目录)
|
|
||||||
{
|
{
|
||||||
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
||||||
|
|
||||||
let log_dir = panic_hook::get_log_dir();
|
let log_dir = panic_hook::get_log_dir();
|
||||||
|
|
||||||
|
// 确保日志目录存在
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&log_dir) {
|
||||||
|
eprintln!("创建日志目录失败: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时删除旧日志文件,实现单文件覆盖效果
|
||||||
|
let log_file_path = log_dir.join("cc-switch.log");
|
||||||
|
let _ = std::fs::remove_file(&log_file_path);
|
||||||
|
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
tauri_plugin_log::Builder::default()
|
tauri_plugin_log::Builder::default()
|
||||||
.level(log::LevelFilter::Info)
|
// 初始化为 Trace,允许后续通过 log::set_max_level() 动态调整级别
|
||||||
|
.level(log::LevelFilter::Trace)
|
||||||
.targets([
|
.targets([
|
||||||
// 输出到控制台
|
|
||||||
Target::new(TargetKind::Stdout),
|
Target::new(TargetKind::Stdout),
|
||||||
// 输出到日志文件
|
|
||||||
Target::new(TargetKind::Folder {
|
Target::new(TargetKind::Folder {
|
||||||
path: log_dir,
|
path: log_dir,
|
||||||
file_name: Some("cc-switch".into()),
|
file_name: Some("cc-switch".into()),
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
.rotation_strategy(RotationStrategy::KeepAll)
|
// 单文件模式:启动时删除旧文件,达到大小时轮转
|
||||||
.max_file_size(5_000_000) // 5MB 单文件上限
|
// 注意:KeepSome(n) 内部会做 n-2 运算,n=1 会导致 usize 下溢
|
||||||
|
// KeepSome(2) 是最小安全值,表示不保留轮转文件
|
||||||
|
.rotation_strategy(RotationStrategy::KeepSome(2))
|
||||||
|
// 单文件大小限制 1GB
|
||||||
|
.max_file_size(1024 * 1024 * 1024)
|
||||||
.timezone_strategy(TimezoneStrategy::UseLocal)
|
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||||
.build(),
|
.build(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// 清理旧日志文件,只保留最近 2 个
|
|
||||||
panic_hook::cleanup_old_logs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化数据库
|
// 初始化数据库
|
||||||
@@ -482,6 +492,17 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||||
|
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||||
|
// 需要遍历 provider 字段下的每个供应商并导入
|
||||||
|
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||||
|
Ok(count) if count > 0 => {
|
||||||
|
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||||
|
}
|
||||||
|
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
|
||||||
|
Err(e) => log::debug!("○ Failed to import OpenCode providers: {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...");
|
||||||
@@ -509,6 +530,14 @@ pub fn run() {
|
|||||||
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
|
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
|
||||||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
|
||||||
|
Ok(count) if count > 0 => {
|
||||||
|
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
|
||||||
|
}
|
||||||
|
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
|
||||||
|
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 导入提示词文件(表空时触发)
|
// 4. 导入提示词文件(表空时触发)
|
||||||
@@ -640,6 +669,19 @@ pub fn run() {
|
|||||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
|
|
||||||
|
// 从数据库加载日志配置并应用
|
||||||
|
{
|
||||||
|
let db = &app.state::<AppState>().db;
|
||||||
|
if let Ok(log_config) = db.get_log_config() {
|
||||||
|
log::set_max_level(log_config.to_level_filter());
|
||||||
|
log::info!(
|
||||||
|
"已加载日志配置: enabled={}, level={}",
|
||||||
|
log_config.enabled,
|
||||||
|
log_config.level
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化 SkillService
|
// 初始化 SkillService
|
||||||
let skill_service = SkillService::new();
|
let skill_service = SkillService::new();
|
||||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||||
@@ -704,6 +746,24 @@ pub fn run() {
|
|||||||
restore_proxy_state_on_startup(&state).await;
|
restore_proxy_state_on_startup(&state).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 静默启动:根据设置决定是否显示主窗口
|
||||||
|
let settings = crate::settings::get_settings();
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
if settings.silent_startup {
|
||||||
|
// 静默启动模式:保持窗口隐藏
|
||||||
|
let _ = window.hide();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let _ = window.set_skip_taskbar(true);
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
tray::apply_tray_policy(app.handle(), false);
|
||||||
|
log::info!("静默启动模式:主窗口已隐藏");
|
||||||
|
} else {
|
||||||
|
// 正常启动模式:显示窗口
|
||||||
|
let _ = window.show();
|
||||||
|
log::info!("正常启动模式:主窗口已显示");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -712,6 +772,7 @@ pub fn run() {
|
|||||||
commands::add_provider,
|
commands::add_provider,
|
||||||
commands::update_provider,
|
commands::update_provider,
|
||||||
commands::delete_provider,
|
commands::delete_provider,
|
||||||
|
commands::remove_provider_from_live_config,
|
||||||
commands::switch_provider,
|
commands::switch_provider,
|
||||||
commands::import_default_config,
|
commands::import_default_config,
|
||||||
commands::get_claude_config_status,
|
commands::get_claude_config_status,
|
||||||
@@ -736,6 +797,8 @@ pub fn run() {
|
|||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
commands::get_rectifier_config,
|
commands::get_rectifier_config,
|
||||||
commands::set_rectifier_config,
|
commands::set_rectifier_config,
|
||||||
|
commands::get_log_config,
|
||||||
|
commands::set_log_config,
|
||||||
commands::restart_app,
|
commands::restart_app,
|
||||||
commands::check_for_updates,
|
commands::check_for_updates,
|
||||||
commands::is_portable_mode,
|
commands::is_portable_mode,
|
||||||
@@ -788,6 +851,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,
|
||||||
@@ -817,6 +881,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,
|
||||||
@@ -833,6 +898,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,
|
||||||
@@ -865,6 +934,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,
|
||||||
@@ -874,28 +947,17 @@ pub fn run() {
|
|||||||
commands::upsert_universal_provider,
|
commands::upsert_universal_provider,
|
||||||
commands::delete_universal_provider,
|
commands::delete_universal_provider,
|
||||||
commands::sync_universal_provider,
|
commands::sync_universal_provider,
|
||||||
|
// OpenCode specific
|
||||||
|
commands::import_opencode_providers_from_live,
|
||||||
|
commands::get_opencode_live_provider_ids,
|
||||||
// Global upstream proxy
|
// Global upstream proxy
|
||||||
commands::get_global_proxy_url,
|
commands::get_global_proxy_url,
|
||||||
commands::set_global_proxy_url,
|
commands::set_global_proxy_url,
|
||||||
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
|
||||||
// Template management
|
commands::set_window_theme,
|
||||||
commands::refresh_template_index,
|
|
||||||
commands::list_template_components,
|
|
||||||
commands::get_template_component,
|
|
||||||
commands::install_template_component,
|
|
||||||
commands::uninstall_template_component,
|
|
||||||
commands::batch_install_template_components,
|
|
||||||
commands::list_template_repos,
|
|
||||||
commands::add_template_repo,
|
|
||||||
commands::remove_template_repo,
|
|
||||||
commands::toggle_template_repo,
|
|
||||||
commands::list_template_categories,
|
|
||||||
commands::list_installed_components,
|
|
||||||
commands::preview_component_content,
|
|
||||||
commands::list_marketplace_bundles,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
|||||||
claude: true,
|
claude: true,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: true,
|
codex: true,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: true,
|
gemini: true,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
//! - `claude` - Claude MCP 同步和导入
|
//! - `claude` - Claude MCP 同步和导入
|
||||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
||||||
//! - `gemini` - Gemini MCP 同步和导入
|
//! - `gemini` - Gemini MCP 同步和导入
|
||||||
|
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
|
||||||
|
|
||||||
mod claude;
|
mod claude;
|
||||||
mod codex;
|
mod codex;
|
||||||
mod gemini;
|
mod gemini;
|
||||||
|
mod opencode;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
// 重新导出公共 API
|
// 重新导出公共 API
|
||||||
@@ -26,3 +28,6 @@ pub use gemini::{
|
|||||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||||
sync_single_server_to_gemini,
|
sync_single_server_to_gemini,
|
||||||
};
|
};
|
||||||
|
pub use opencode::{
|
||||||
|
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
//! OpenCode MCP 同步和导入模块
|
||||||
|
//!
|
||||||
|
//! 本模块处理 CC Switch 统一 MCP 格式与 OpenCode 格式之间的转换。
|
||||||
|
//!
|
||||||
|
//! ## 格式差异
|
||||||
|
//!
|
||||||
|
//! | CC Switch 统一格式 | OpenCode 格式 |
|
||||||
|
//! |----------------------|---------------------|
|
||||||
|
//! | `type: "stdio"` | `type: "local"` |
|
||||||
|
//! | `command` + `args` | `command: [cmd, ...args]` |
|
||||||
|
//! | `env` | `environment` |
|
||||||
|
//! | `type: "sse"/"http"` | `type: "remote"` |
|
||||||
|
//! | `url` | `url` |
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::opencode_config;
|
||||||
|
|
||||||
|
use super::validation::validate_server_spec;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if OpenCode MCP sync should proceed
|
||||||
|
fn should_sync_opencode_mcp() -> bool {
|
||||||
|
// Skip if OpenCode config directory doesn't exist
|
||||||
|
opencode_config::get_opencode_dir().exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Format Conversion: CC Switch → OpenCode
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Convert CC Switch unified format to OpenCode format
|
||||||
|
///
|
||||||
|
/// Conversion rules:
|
||||||
|
/// - `stdio` → `local`, command+args → command array, env → environment
|
||||||
|
/// - `sse`/`http` → `remote`, url preserved
|
||||||
|
pub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||||
|
let obj = spec
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
|
||||||
|
|
||||||
|
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||||
|
|
||||||
|
let mut result = serde_json::Map::new();
|
||||||
|
|
||||||
|
match typ {
|
||||||
|
"stdio" => {
|
||||||
|
// Convert to "local" type
|
||||||
|
result.insert("type".into(), json!("local"));
|
||||||
|
|
||||||
|
// Merge command and args into a single array
|
||||||
|
let cmd = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let mut command_arr = vec![json!(cmd)];
|
||||||
|
|
||||||
|
if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
|
||||||
|
for arg in args {
|
||||||
|
command_arr.push(arg.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.insert("command".into(), Value::Array(command_arr));
|
||||||
|
|
||||||
|
// Convert env → environment
|
||||||
|
if let Some(env) = obj.get("env") {
|
||||||
|
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||||
|
result.insert("environment".into(), env.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enabled flag (OpenCode expects this)
|
||||||
|
result.insert("enabled".into(), json!(true));
|
||||||
|
}
|
||||||
|
"sse" | "http" => {
|
||||||
|
// Convert to "remote" type
|
||||||
|
result.insert("type".into(), json!("remote"));
|
||||||
|
|
||||||
|
// Preserve url
|
||||||
|
if let Some(url) = obj.get("url") {
|
||||||
|
result.insert("url".into(), url.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert headers if present
|
||||||
|
if let Some(headers) = obj.get("headers") {
|
||||||
|
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||||
|
{
|
||||||
|
result.insert("headers".into(), headers.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enabled flag
|
||||||
|
result.insert("enabled".into(), json!(true));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Value::Object(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Format Conversion: OpenCode → CC Switch
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Convert OpenCode format to CC Switch unified format
|
||||||
|
///
|
||||||
|
/// Conversion rules:
|
||||||
|
/// - `local` → `stdio`, command array → command+args, environment → env
|
||||||
|
/// - `remote` → `sse`, url preserved
|
||||||
|
pub fn convert_from_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||||
|
let obj = spec
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?;
|
||||||
|
|
||||||
|
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local");
|
||||||
|
|
||||||
|
let mut result = serde_json::Map::new();
|
||||||
|
|
||||||
|
match typ {
|
||||||
|
"local" => {
|
||||||
|
// Convert to "stdio" type
|
||||||
|
result.insert("type".into(), json!("stdio"));
|
||||||
|
|
||||||
|
// Split command array into command and args
|
||||||
|
if let Some(cmd_arr) = obj.get("command").and_then(|v| v.as_array()) {
|
||||||
|
if !cmd_arr.is_empty() {
|
||||||
|
// First element is the command
|
||||||
|
if let Some(cmd) = cmd_arr.first().and_then(|v| v.as_str()) {
|
||||||
|
result.insert("command".into(), json!(cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest are args
|
||||||
|
if cmd_arr.len() > 1 {
|
||||||
|
let args: Vec<Value> = cmd_arr[1..].to_vec();
|
||||||
|
result.insert("args".into(), Value::Array(args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert environment → env
|
||||||
|
if let Some(env) = obj.get("environment") {
|
||||||
|
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||||
|
result.insert("env".into(), env.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"remote" => {
|
||||||
|
// Convert to "sse" type (default remote protocol)
|
||||||
|
result.insert("type".into(), json!("sse"));
|
||||||
|
|
||||||
|
// Preserve url
|
||||||
|
if let Some(url) = obj.get("url") {
|
||||||
|
result.insert("url".into(), url.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve headers
|
||||||
|
if let Some(headers) = obj.get("headers") {
|
||||||
|
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||||
|
{
|
||||||
|
result.insert("headers".into(), headers.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"Unknown OpenCode MCP type: {typ}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Value::Object(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API: Sync Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Sync a single MCP server to OpenCode live config
|
||||||
|
pub fn sync_single_server_to_opencode(
|
||||||
|
_config: &MultiAppConfig,
|
||||||
|
id: &str,
|
||||||
|
server_spec: &Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if !should_sync_opencode_mcp() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to OpenCode format
|
||||||
|
let opencode_spec = convert_to_opencode_format(server_spec)?;
|
||||||
|
|
||||||
|
// Set in OpenCode config
|
||||||
|
opencode_config::set_mcp_server(id, opencode_spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a single MCP server from OpenCode live config
|
||||||
|
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> {
|
||||||
|
if !should_sync_opencode_mcp() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
opencode_config::remove_mcp_server(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import MCP servers from OpenCode config to unified structure
|
||||||
|
///
|
||||||
|
/// Existing servers will have OpenCode app enabled without overwriting other fields.
|
||||||
|
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||||
|
let mcp_map = opencode_config::get_mcp_servers()?;
|
||||||
|
if mcp_map.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure servers map exists
|
||||||
|
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||||
|
|
||||||
|
let mut changed = 0;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for (id, spec) in mcp_map {
|
||||||
|
// Convert from OpenCode format to unified format
|
||||||
|
let unified_spec = match convert_from_opencode_format(&spec) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Skip invalid OpenCode MCP server '{id}': {e}");
|
||||||
|
errors.push(format!("{id}: {e}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the converted spec
|
||||||
|
if let Err(e) = validate_server_spec(&unified_spec) {
|
||||||
|
log::warn!("Skip invalid MCP server '{id}' after conversion: {e}");
|
||||||
|
errors.push(format!("{id}: {e}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(existing) = servers.get_mut(&id) {
|
||||||
|
// Existing server: just enable OpenCode app
|
||||||
|
if !existing.apps.opencode {
|
||||||
|
existing.apps.opencode = true;
|
||||||
|
changed += 1;
|
||||||
|
log::info!("MCP server '{id}' enabled for OpenCode");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New server: default to only OpenCode enabled
|
||||||
|
servers.insert(
|
||||||
|
id.clone(),
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name: id.clone(),
|
||||||
|
server: unified_spec,
|
||||||
|
apps: McpApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
opencode: true,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
changed += 1;
|
||||||
|
log::info!("Imported new MCP server '{id}' from OpenCode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
log::warn!(
|
||||||
|
"Import completed with {} failures: {:?}",
|
||||||
|
errors.len(),
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_stdio_to_local() {
|
||||||
|
let spec = json!({
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||||
|
"env": { "HOME": "/Users/test" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_to_opencode_format(&spec).unwrap();
|
||||||
|
assert_eq!(result["type"], "local");
|
||||||
|
assert_eq!(result["command"][0], "npx");
|
||||||
|
assert_eq!(result["command"][1], "-y");
|
||||||
|
assert_eq!(
|
||||||
|
result["command"][2],
|
||||||
|
"@modelcontextprotocol/server-filesystem"
|
||||||
|
);
|
||||||
|
assert_eq!(result["environment"]["HOME"], "/Users/test");
|
||||||
|
assert_eq!(result["enabled"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_sse_to_remote() {
|
||||||
|
let spec = json!({
|
||||||
|
"type": "sse",
|
||||||
|
"url": "https://example.com/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer xxx" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_to_opencode_format(&spec).unwrap();
|
||||||
|
assert_eq!(result["type"], "remote");
|
||||||
|
assert_eq!(result["url"], "https://example.com/mcp");
|
||||||
|
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||||
|
assert_eq!(result["enabled"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_local_to_stdio() {
|
||||||
|
let spec = json!({
|
||||||
|
"type": "local",
|
||||||
|
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem"],
|
||||||
|
"environment": { "HOME": "/Users/test" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_from_opencode_format(&spec).unwrap();
|
||||||
|
assert_eq!(result["type"], "stdio");
|
||||||
|
assert_eq!(result["command"], "npx");
|
||||||
|
assert_eq!(result["args"][0], "-y");
|
||||||
|
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
|
||||||
|
assert_eq!(result["env"]["HOME"], "/Users/test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_remote_to_sse() {
|
||||||
|
let spec = json!({
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://example.com/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer xxx" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_from_opencode_format(&spec).unwrap();
|
||||||
|
assert_eq!(result["type"], "sse");
|
||||||
|
assert_eq!(result["url"], "https://example.com/mcp");
|
||||||
|
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//! OpenCode 配置文件读写模块
|
||||||
|
//!
|
||||||
|
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
|
||||||
|
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
|
||||||
|
//!
|
||||||
|
//! ## 配置文件格式
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! {
|
||||||
|
//! "$schema": "https://opencode.ai/config.json",
|
||||||
|
//! "provider": {
|
||||||
|
//! "my-provider": {
|
||||||
|
//! "npm": "@ai-sdk/openai-compatible",
|
||||||
|
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
|
||||||
|
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
|
||||||
|
//! }
|
||||||
|
//! },
|
||||||
|
//! "mcp": {
|
||||||
|
//! "my-server": { "type": "local", "command": ["..."] }
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::config::write_json_file;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::OpenCodeProviderConfig;
|
||||||
|
use crate::settings::get_opencode_override_dir;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Path Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取 OpenCode 配置目录
|
||||||
|
///
|
||||||
|
/// 默认路径: `~/.config/opencode/`
|
||||||
|
/// 可通过 settings.opencode_config_dir 覆盖
|
||||||
|
pub fn get_opencode_dir() -> PathBuf {
|
||||||
|
if let Some(override_dir) = get_opencode_override_dir() {
|
||||||
|
return override_dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有平台统一使用 ~/.config/opencode
|
||||||
|
dirs::home_dir()
|
||||||
|
.map(|h| h.join(".config").join("opencode"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 OpenCode 配置文件路径
|
||||||
|
///
|
||||||
|
/// 返回 `~/.config/opencode/opencode.json`
|
||||||
|
pub fn get_opencode_config_path() -> PathBuf {
|
||||||
|
get_opencode_dir().join("opencode.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 OpenCode 环境变量文件路径(如果存在)
|
||||||
|
///
|
||||||
|
/// 返回 `~/.config/opencode/.env`
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_opencode_env_path() -> PathBuf {
|
||||||
|
get_opencode_dir().join(".env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Read/Write Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 读取 OpenCode 配置文件
|
||||||
|
///
|
||||||
|
/// 返回完整的配置 JSON 对象
|
||||||
|
pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||||
|
let path = get_opencode_config_path();
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
// Return empty config with schema
|
||||||
|
return Ok(json!({
|
||||||
|
"$schema": "https://opencode.ai/config.json"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 OpenCode 配置文件(原子写入)
|
||||||
|
///
|
||||||
|
/// 使用临时文件 + 重命名确保原子性
|
||||||
|
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||||
|
let path = get_opencode_config_path();
|
||||||
|
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
|
||||||
|
write_json_file(&path, config)?;
|
||||||
|
|
||||||
|
log::debug!("OpenCode config written to {path:?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Provider Functions (Untyped - for raw JSON operations)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取所有供应商配置(原始 JSON)
|
||||||
|
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||||
|
let config = read_opencode_config()?;
|
||||||
|
Ok(config
|
||||||
|
.get("provider")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置供应商配置(原始 JSON)
|
||||||
|
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
|
if full_config.get("provider").is_none() {
|
||||||
|
full_config["provider"] = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(providers) = full_config
|
||||||
|
.get_mut("provider")
|
||||||
|
.and_then(|v| v.as_object_mut())
|
||||||
|
{
|
||||||
|
providers.insert(id.to_string(), config);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&full_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除供应商配置
|
||||||
|
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||||
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
|
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||||
|
providers.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Provider Functions (Typed - using OpenCodeProviderConfig)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取所有供应商配置(类型化)
|
||||||
|
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
||||||
|
let providers = get_providers()?;
|
||||||
|
let mut result = IndexMap::new();
|
||||||
|
|
||||||
|
for (id, value) in providers {
|
||||||
|
match serde_json::from_value::<OpenCodeProviderConfig>(value.clone()) {
|
||||||
|
Ok(config) => {
|
||||||
|
result.insert(id, config);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to parse provider '{id}': {e}");
|
||||||
|
// Skip invalid providers but continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置供应商配置(类型化)
|
||||||
|
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
|
||||||
|
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
set_provider(id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MCP Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取所有 MCP 服务器配置
|
||||||
|
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
||||||
|
let config = read_opencode_config()?;
|
||||||
|
Ok(config
|
||||||
|
.get("mcp")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 MCP 服务器配置
|
||||||
|
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
|
if full_config.get("mcp").is_none() {
|
||||||
|
full_config["mcp"] = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mcp) = full_config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||||
|
mcp.insert(id.to_string(), config);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&full_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 MCP 服务器配置
|
||||||
|
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||||
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
|
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||||
|
mcp.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&config)
|
||||||
|
}
|
||||||
@@ -12,9 +12,6 @@ use std::sync::OnceLock;
|
|||||||
/// 应用版本号(从 Cargo.toml 读取)
|
/// 应用版本号(从 Cargo.toml 读取)
|
||||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
/// 日志文件保留数量
|
|
||||||
const LOG_FILES_TO_KEEP: usize = 2;
|
|
||||||
|
|
||||||
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
pub fn init_app_config_dir(dir: PathBuf) {
|
pub fn init_app_config_dir(dir: PathBuf) {
|
||||||
@@ -46,48 +43,6 @@ pub fn get_log_dir() -> PathBuf {
|
|||||||
get_app_config_dir().join("logs")
|
get_app_config_dir().join("logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清理旧日志文件,只保留最近 N 个
|
|
||||||
///
|
|
||||||
/// 在应用启动时调用,确保日志文件不会无限增长。
|
|
||||||
pub fn cleanup_old_logs() {
|
|
||||||
let log_dir = get_log_dir();
|
|
||||||
|
|
||||||
if !log_dir.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取目录中的所有 .log 文件
|
|
||||||
let mut log_files: Vec<_> = match std::fs::read_dir(&log_dir) {
|
|
||||||
Ok(entries) => entries
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.map(|e| e.path())
|
|
||||||
.filter(|p| p.extension().map(|ext| ext == "log").unwrap_or(false))
|
|
||||||
.collect(),
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果文件数量不超过保留数量,无需清理
|
|
||||||
if log_files.len() <= LOG_FILES_TO_KEEP {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按修改时间排序(最新的在前)
|
|
||||||
log_files.sort_by(|a, b| {
|
|
||||||
let time_a = a.metadata().and_then(|m| m.modified()).ok();
|
|
||||||
let time_b = b.metadata().and_then(|m| m.modified()).ok();
|
|
||||||
time_b.cmp(&time_a) // 降序
|
|
||||||
});
|
|
||||||
|
|
||||||
// 删除多余的旧文件
|
|
||||||
for old_file in log_files.into_iter().skip(LOG_FILES_TO_KEEP) {
|
|
||||||
if let Err(e) = std::fs::remove_file(&old_file) {
|
|
||||||
log::warn!("清理旧日志文件失败 {}: {e}", old_file.display());
|
|
||||||
} else {
|
|
||||||
log::info!("已清理旧日志文件: {}", old_file.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安全获取环境信息(不会 panic)
|
/// 安全获取环境信息(不会 panic)
|
||||||
fn get_system_info() -> String {
|
fn get_system_info() -> String {
|
||||||
let os = std::env::consts::OS;
|
let os = std::env::consts::OS;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path;
|
|||||||
use crate::config::get_claude_settings_path;
|
use crate::config::get_claude_settings_path;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::gemini_config::get_gemini_dir;
|
use crate::gemini_config::get_gemini_dir;
|
||||||
|
use crate::opencode_config::get_opencode_dir;
|
||||||
|
|
||||||
/// 返回指定应用所使用的提示词文件路径。
|
/// 返回指定应用所使用的提示词文件路径。
|
||||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||||
@@ -12,12 +13,14 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
|||||||
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||||
AppType::Gemini => get_gemini_dir(),
|
AppType::Gemini => get_gemini_dir(),
|
||||||
|
AppType::OpenCode => get_opencode_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let filename = match app {
|
let filename = match app {
|
||||||
AppType::Claude => "CLAUDE.md",
|
AppType::Claude => "CLAUDE.md",
|
||||||
AppType::Codex => "AGENTS.md",
|
AppType::Codex => "AGENTS.md",
|
||||||
AppType::Gemini => "GEMINI.md",
|
AppType::Gemini => "GEMINI.md",
|
||||||
|
AppType::OpenCode => "AGENTS.md",
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(base_dir.join(filename))
|
Ok(base_dir.join(filename))
|
||||||
|
|||||||
+482
-4
@@ -98,6 +98,10 @@ pub struct UsageScript {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "userId")]
|
#[serde(rename = "userId")]
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// 模板类型(用于后端判断验证规则)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "templateType")]
|
||||||
|
pub template_type: Option<String>,
|
||||||
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "autoQueryInterval")]
|
#[serde(rename = "autoQueryInterval")]
|
||||||
@@ -138,6 +142,55 @@ pub struct UsageResult {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 供应商单独的模型测试配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ProviderTestConfig {
|
||||||
|
/// 是否启用单独配置(false 时使用全局配置)
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 测试用的模型名称(覆盖全局配置)
|
||||||
|
#[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub test_model: Option<String>,
|
||||||
|
/// 超时时间(秒)
|
||||||
|
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout_secs: Option<u64>,
|
||||||
|
/// 测试提示词
|
||||||
|
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub test_prompt: Option<String>,
|
||||||
|
/// 降级阈值(毫秒)
|
||||||
|
#[serde(
|
||||||
|
rename = "degradedThresholdMs",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub degraded_threshold_ms: Option<u64>,
|
||||||
|
/// 最大重试次数
|
||||||
|
#[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_retries: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 供应商单独的代理配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ProviderProxyConfig {
|
||||||
|
/// 是否启用单独配置(false 时使用全局/系统代理)
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 代理类型:http, https, socks5
|
||||||
|
#[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_type: Option<String>,
|
||||||
|
/// 代理主机
|
||||||
|
#[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_host: Option<String>,
|
||||||
|
/// 代理端口
|
||||||
|
#[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_port: Option<u16>,
|
||||||
|
/// 代理用户名(可选)
|
||||||
|
#[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_username: Option<String>,
|
||||||
|
/// 代理密码(可选)
|
||||||
|
#[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 供应商元数据
|
/// 供应商元数据
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderMeta {
|
pub struct ProviderMeta {
|
||||||
@@ -162,12 +215,26 @@ 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>,
|
||||||
/// 每月消费限额(USD)
|
/// 每月消费限额(USD)
|
||||||
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
|
||||||
pub limit_monthly_usd: Option<String>,
|
pub limit_monthly_usd: Option<String>,
|
||||||
|
/// 供应商单独的模型测试配置
|
||||||
|
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub test_config: Option<ProviderTestConfig>,
|
||||||
|
/// 供应商单独的代理配置
|
||||||
|
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_config: Option<ProviderProxyConfig>,
|
||||||
|
/// Claude API 格式(仅 Claude 供应商使用)
|
||||||
|
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||||
|
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||||
|
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
@@ -379,11 +446,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 内容
|
||||||
@@ -458,3 +532,407 @@ requires_openai_auth = true"#
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenCode 供应商配置结构
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// OpenCode 供应商的 settings_config 结构
|
||||||
|
///
|
||||||
|
/// OpenCode 使用 AI SDK 包名来指定供应商类型,与其他应用的配置格式不同。
|
||||||
|
/// 配置示例:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "npm": "@ai-sdk/openai-compatible",
|
||||||
|
/// "options": { "baseURL": "https://api.example.com/v1", "apiKey": "sk-xxx" },
|
||||||
|
/// "models": { "gpt-4o": { "name": "GPT-4o" } }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeProviderConfig {
|
||||||
|
/// AI SDK 包名,如 "@ai-sdk/openai-compatible", "@ai-sdk/anthropic"
|
||||||
|
pub npm: String,
|
||||||
|
|
||||||
|
/// 供应商名称(可选,用于显示)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
|
/// 供应商选项(API 密钥、基础 URL 等)
|
||||||
|
#[serde(default)]
|
||||||
|
pub options: OpenCodeProviderOptions,
|
||||||
|
|
||||||
|
/// 模型定义映射
|
||||||
|
#[serde(default)]
|
||||||
|
pub models: HashMap<String, OpenCodeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OpenCodeProviderConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
npm: "@ai-sdk/openai-compatible".to_string(),
|
||||||
|
name: None,
|
||||||
|
options: OpenCodeProviderOptions::default(),
|
||||||
|
models: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenCode 供应商选项
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct OpenCodeProviderOptions {
|
||||||
|
/// API 基础 URL
|
||||||
|
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
|
||||||
|
/// API 密钥(支持环境变量引用,如 "{env:API_KEY}")
|
||||||
|
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
|
||||||
|
/// 自定义请求头
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub headers: Option<HashMap<String, String>>,
|
||||||
|
|
||||||
|
/// 额外选项(timeout, setCacheKey 等)
|
||||||
|
/// 使用 flatten 捕获所有未明确定义的字段
|
||||||
|
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub extra: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenCode 模型定义
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeModel {
|
||||||
|
/// 模型显示名称
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// 模型限制(上下文和输出 token 数)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<OpenCodeModelLimit>,
|
||||||
|
|
||||||
|
/// 模型额外选项(provider 路由等)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub options: Option<HashMap<String, Value>>,
|
||||||
|
|
||||||
|
/// 额外字段(cost、modalities、thinking、variants 等)
|
||||||
|
/// 使用 flatten 捕获所有未明确定义的字段
|
||||||
|
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub extra: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenCode 模型限制
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct OpenCodeModelLimit {
|
||||||
|
/// 上下文 token 限制
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context: Option<u64>,
|
||||||
|
|
||||||
|
/// 输出 token 限制
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub output: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
||||||
|
ProviderManager, ProviderMeta, UniversalProvider,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_meta_serializes_pricing_model_source() {
|
||||||
|
let mut meta = ProviderMeta::default();
|
||||||
|
meta.pricing_model_source = Some("response".to_string());
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value
|
||||||
|
.get("pricingModelSource")
|
||||||
|
.and_then(|item| item.as_str()),
|
||||||
|
Some("response")
|
||||||
|
);
|
||||||
|
assert!(value.get("pricing_model_source").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_meta_omits_pricing_model_source_when_none() {
|
||||||
|
let meta = ProviderMeta::default();
|
||||||
|
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||||
|
|
||||||
|
assert!(value.get("pricingModelSource").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_with_id_populates_defaults() {
|
||||||
|
let settings_config = json!({
|
||||||
|
"env": { "API_KEY": "test" }
|
||||||
|
});
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"provider-1".to_string(),
|
||||||
|
"Provider".to_string(),
|
||||||
|
settings_config.clone(),
|
||||||
|
Some("https://example.com".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(provider.id, "provider-1");
|
||||||
|
assert_eq!(provider.name, "Provider");
|
||||||
|
assert_eq!(provider.settings_config, settings_config);
|
||||||
|
assert_eq!(provider.website_url.as_deref(), Some("https://example.com"));
|
||||||
|
assert!(provider.category.is_none());
|
||||||
|
assert!(provider.created_at.is_none());
|
||||||
|
assert!(provider.sort_index.is_none());
|
||||||
|
assert!(provider.notes.is_none());
|
||||||
|
assert!(provider.meta.is_none());
|
||||||
|
assert!(provider.icon.is_none());
|
||||||
|
assert!(provider.icon_color.is_none());
|
||||||
|
assert!(!provider.in_failover_queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_manager_get_all_providers_returns_map() {
|
||||||
|
let mut manager = ProviderManager::default();
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"provider-1".to_string(),
|
||||||
|
"Provider".to_string(),
|
||||||
|
json!({ "env": {} }),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
manager.providers.insert("provider-1".to_string(), provider);
|
||||||
|
|
||||||
|
assert_eq!(manager.get_all_providers().len(), 1);
|
||||||
|
assert!(manager.get_all_providers().contains_key("provider-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn universal_provider_to_claude_provider_uses_models() {
|
||||||
|
let mut universal = UniversalProvider::new(
|
||||||
|
"u1".to_string(),
|
||||||
|
"Universal".to_string(),
|
||||||
|
"newapi".to_string(),
|
||||||
|
"https://api.example.com".to_string(),
|
||||||
|
"api-key".to_string(),
|
||||||
|
);
|
||||||
|
universal.apps.claude = true;
|
||||||
|
universal.models.claude = Some(ClaudeModelConfig {
|
||||||
|
model: Some("claude-main".to_string()),
|
||||||
|
haiku_model: Some("claude-haiku".to_string()),
|
||||||
|
sonnet_model: Some("claude-sonnet".to_string()),
|
||||||
|
opus_model: Some("claude-opus".to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider = universal.to_claude_provider().expect("claude provider");
|
||||||
|
|
||||||
|
assert_eq!(provider.id, "universal-claude-u1");
|
||||||
|
assert_eq!(provider.name, "Universal");
|
||||||
|
assert_eq!(provider.category.as_deref(), Some("aggregator"));
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/ANTHROPIC_MODEL")
|
||||||
|
.and_then(|item| item.as_str()),
|
||||||
|
Some("claude-main")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||||
|
.and_then(|item| item.as_str()),
|
||||||
|
Some("claude-haiku")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||||
|
.and_then(|item| item.as_str()),
|
||||||
|
Some("claude-sonnet")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||||
|
.and_then(|item| item.as_str()),
|
||||||
|
Some("claude-opus")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn universal_provider_to_claude_provider_disabled_returns_none() {
|
||||||
|
let universal = UniversalProvider::new(
|
||||||
|
"u1".to_string(),
|
||||||
|
"Universal".to_string(),
|
||||||
|
"newapi".to_string(),
|
||||||
|
"https://api.example.com".to_string(),
|
||||||
|
"api-key".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(universal.to_claude_provider().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn universal_provider_to_codex_provider_appends_v1() {
|
||||||
|
let mut universal = UniversalProvider::new(
|
||||||
|
"u1".to_string(),
|
||||||
|
"Universal".to_string(),
|
||||||
|
"newapi".to_string(),
|
||||||
|
"https://api.example.com".to_string(),
|
||||||
|
"api-key".to_string(),
|
||||||
|
);
|
||||||
|
universal.apps.codex = true;
|
||||||
|
universal.models.codex = Some(CodexModelConfig {
|
||||||
|
model: Some("gpt-4o-mini".to_string()),
|
||||||
|
reasoning_effort: Some("low".to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider = universal.to_codex_provider().expect("codex provider");
|
||||||
|
let config = provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|item| item.as_str())
|
||||||
|
.expect("config toml");
|
||||||
|
|
||||||
|
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/auth/OPENAI_API_KEY")
|
||||||
|
.and_then(|item| item.as_str()),
|
||||||
|
Some("api-key")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn universal_provider_to_codex_provider_keeps_v1_suffix() {
|
||||||
|
let mut universal = UniversalProvider::new(
|
||||||
|
"u1".to_string(),
|
||||||
|
"Universal".to_string(),
|
||||||
|
"newapi".to_string(),
|
||||||
|
"https://api.example.com/v1".to_string(),
|
||||||
|
"api-key".to_string(),
|
||||||
|
);
|
||||||
|
universal.apps.codex = true;
|
||||||
|
|
||||||
|
let provider = universal.to_codex_provider().expect("codex provider");
|
||||||
|
let config = provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|item| item.as_str())
|
||||||
|
.expect("config toml");
|
||||||
|
|
||||||
|
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn universal_provider_to_codex_provider_disabled_returns_none() {
|
||||||
|
let universal = UniversalProvider::new(
|
||||||
|
"u1".to_string(),
|
||||||
|
"Universal".to_string(),
|
||||||
|
"newapi".to_string(),
|
||||||
|
"https://api.example.com".to_string(),
|
||||||
|
"api-key".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(universal.to_codex_provider().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn universal_provider_to_gemini_provider_defaults_model() {
|
||||||
|
let mut universal = UniversalProvider::new(
|
||||||
|
"u1".to_string(),
|
||||||
|
"Universal".to_string(),
|
||||||
|
"newapi".to_string(),
|
||||||
|
"https://api.example.com".to_string(),
|
||||||
|
"api-key".to_string(),
|
||||||
|
);
|
||||||
|
universal.apps.gemini = true;
|
||||||
|
|
||||||
|
let provider = universal.to_gemini_provider().expect("gemini provider");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/GEMINI_MODEL")
|
||||||
|
.and_then(|item| item.as_str()),
|
||||||
|
Some("gemini-2.5-pro")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn universal_provider_to_gemini_provider_uses_model() {
|
||||||
|
let mut universal = UniversalProvider::new(
|
||||||
|
"u1".to_string(),
|
||||||
|
"Universal".to_string(),
|
||||||
|
"newapi".to_string(),
|
||||||
|
"https://api.example.com".to_string(),
|
||||||
|
"api-key".to_string(),
|
||||||
|
);
|
||||||
|
universal.apps.gemini = true;
|
||||||
|
universal.models.gemini = Some(GeminiModelConfig {
|
||||||
|
model: Some("gemini-custom".to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider = universal.to_gemini_provider().expect("gemini provider");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/GEMINI_MODEL")
|
||||||
|
.and_then(|item| item.as_str()),
|
||||||
|
Some("gemini-custom")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opencode_provider_config_defaults() {
|
||||||
|
let config = OpenCodeProviderConfig::default();
|
||||||
|
assert_eq!(config.npm, "@ai-sdk/openai-compatible");
|
||||||
|
assert!(config.name.is_none());
|
||||||
|
assert!(config.models.is_empty());
|
||||||
|
assert!(config.options.base_url.is_none());
|
||||||
|
assert!(config.options.api_key.is_none());
|
||||||
|
assert!(config.options.headers.is_none());
|
||||||
|
assert!(config.options.extra.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn universal_codex_provider_origin_base_url_adds_v1() {
|
||||||
|
let mut p = UniversalProvider::new(
|
||||||
|
"id".to_string(),
|
||||||
|
"Test".to_string(),
|
||||||
|
"custom".to_string(),
|
||||||
|
"https://api.openai.com".to_string(),
|
||||||
|
"sk-test".to_string(),
|
||||||
|
);
|
||||||
|
p.apps.codex = true;
|
||||||
|
|
||||||
|
let provider = p.to_codex_provider().expect("should build codex provider");
|
||||||
|
let toml = provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.expect("config should be a toml string");
|
||||||
|
|
||||||
|
assert!(toml.contains("base_url = \"https://api.openai.com/v1\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn universal_codex_provider_custom_prefix_does_not_force_v1() {
|
||||||
|
let mut p = UniversalProvider::new(
|
||||||
|
"id".to_string(),
|
||||||
|
"Test".to_string(),
|
||||||
|
"custom".to_string(),
|
||||||
|
"https://example.com/openai".to_string(),
|
||||||
|
"sk-test".to_string(),
|
||||||
|
);
|
||||||
|
p.apps.codex = true;
|
||||||
|
|
||||||
|
let provider = p.to_codex_provider().expect("should build codex provider");
|
||||||
|
let toml = provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.expect("config should be a toml string");
|
||||||
|
|
||||||
|
assert!(toml.contains("base_url = \"https://example.com/openai\""));
|
||||||
|
assert!(!toml.contains("https://example.com/openai/v1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const HEADER_BLACKLIST: &[&str] = &[
|
|||||||
// 认证类(会被覆盖)
|
// 认证类(会被覆盖)
|
||||||
"authorization",
|
"authorization",
|
||||||
"x-api-key",
|
"x-api-key",
|
||||||
|
"x-goog-api-key",
|
||||||
// 连接类(由 HTTP 客户端管理)
|
// 连接类(由 HTTP 客户端管理)
|
||||||
"host",
|
"host",
|
||||||
"content-length",
|
"content-length",
|
||||||
@@ -585,8 +586,9 @@ impl RequestForwarder {
|
|||||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||||
|
|
||||||
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
|
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||||
let client = super::http_client::get();
|
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||||
|
let client = super::http_client::get_for_provider(proxy_config);
|
||||||
let mut request = client.post(&url);
|
let mut request = client.post(&url);
|
||||||
|
|
||||||
// 只有当 timeout > 0 时才设置请求超时
|
// 只有当 timeout > 0 时才设置请求超时
|
||||||
@@ -662,6 +664,21 @@ impl RequestForwarder {
|
|||||||
request = request.header("anthropic-version", version_str);
|
request = request.header("anthropic-version", version_str);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 输出请求信息日志
|
||||||
|
let tag = adapter.name();
|
||||||
|
let request_model = filtered_body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("<none>");
|
||||||
|
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
|
||||||
|
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
||||||
|
log::debug!(
|
||||||
|
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
||||||
|
body_str.len(),
|
||||||
|
body_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
||||||
if e.is_timeout() {
|
if e.is_timeout() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
//! 提供支持全局代理配置的 HTTP 客户端。
|
//! 提供支持全局代理配置的 HTTP 客户端。
|
||||||
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
|
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
|
||||||
|
|
||||||
|
use crate::provider::ProviderProxyConfig;
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use std::env;
|
||||||
|
use std::net::IpAddr;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -14,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 客户端
|
||||||
///
|
///
|
||||||
/// 应在应用启动时调用一次。
|
/// 应在应用启动时调用一次。
|
||||||
@@ -155,23 +185,15 @@ pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
|||||||
|
|
||||||
/// 获取全局 HTTP 客户端
|
/// 获取全局 HTTP 客户端
|
||||||
///
|
///
|
||||||
/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。
|
/// 返回配置了代理的客户端(如果已配置代理),否则返回跟随系统代理的客户端。
|
||||||
pub fn get() -> Client {
|
pub fn get() -> Client {
|
||||||
GLOBAL_CLIENT
|
GLOBAL_CLIENT
|
||||||
.get()
|
.get()
|
||||||
.and_then(|lock| lock.read().ok())
|
.and_then(|lock| lock.read().ok())
|
||||||
.map(|c| c.clone())
|
.map(|c| c.clone())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
// 如果还没初始化,创建一个默认客户端(配置与 build_client 一致)
|
|
||||||
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
|
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
|
||||||
Client::builder()
|
build_client(None).unwrap_or_default()
|
||||||
.timeout(Duration::from_secs(600))
|
|
||||||
.connect_timeout(Duration::from_secs(30))
|
|
||||||
.pool_max_idle_per_host(10)
|
|
||||||
.tcp_keepalive(Duration::from_secs(60))
|
|
||||||
.no_proxy()
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +221,7 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
|||||||
.pool_max_idle_per_host(10)
|
.pool_max_idle_per_host(10)
|
||||||
.tcp_keepalive(Duration::from_secs(60));
|
.tcp_keepalive(Duration::from_secs(60));
|
||||||
|
|
||||||
// 有代理地址则使用代理,否则直连
|
// 有代理地址则使用代理,否则跟随系统代理
|
||||||
if let Some(url) = proxy_url {
|
if let Some(url) = proxy_url {
|
||||||
// 先验证 URL 格式和 scheme
|
// 先验证 URL 格式和 scheme
|
||||||
let parsed = url::Url::parse(url)
|
let parsed = url::Url::parse(url)
|
||||||
@@ -219,8 +241,16 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
|||||||
builder = builder.proxy(proxy);
|
builder = builder.proxy(proxy);
|
||||||
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
|
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
|
||||||
} else {
|
} else {
|
||||||
builder = builder.no_proxy();
|
// 未设置全局代理时,让 reqwest 自动检测系统代理(环境变量)
|
||||||
log::debug!("[GlobalProxy] Direct connection (no proxy)");
|
// 若系统代理指向本机,禁用系统代理避免自环
|
||||||
|
if system_proxy_points_to_loopback() {
|
||||||
|
builder = builder.no_proxy();
|
||||||
|
log::warn!(
|
||||||
|
"[GlobalProxy] System proxy points to localhost, bypassing to avoid recursion"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::debug!("[GlobalProxy] Following system proxy (no explicit proxy configured)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
builder
|
builder
|
||||||
@@ -228,6 +258,58 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
|||||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))
|
.map_err(|e| format!("Failed to build HTTP client: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn system_proxy_points_to_loopback() -> bool {
|
||||||
|
const KEYS: [&str; 6] = [
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"http_proxy",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"https_proxy",
|
||||||
|
"ALL_PROXY",
|
||||||
|
"all_proxy",
|
||||||
|
];
|
||||||
|
|
||||||
|
KEYS.iter()
|
||||||
|
.filter_map(|key| env::var(key).ok())
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.any(|value| proxy_points_to_loopback(&value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxy_points_to_loopback(value: &str) -> bool {
|
||||||
|
fn host_is_loopback(host: &str) -> bool {
|
||||||
|
if host.eq_ignore_ascii_case("localhost") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
host.parse::<IpAddr>()
|
||||||
|
.map(|ip| ip.is_loopback())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否指向 CC Switch 自己的代理端口
|
||||||
|
// 只有指向自己的代理才需要跳过,避免递归
|
||||||
|
fn is_cc_switch_proxy_port(port: Option<u16>) -> bool {
|
||||||
|
let cc_switch_port = get_proxy_port();
|
||||||
|
port == Some(cc_switch_port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(parsed) = url::Url::parse(value) {
|
||||||
|
if let Some(host) = parsed.host_str() {
|
||||||
|
// 只有当主机是 loopback 且端口是 CC Switch 的端口时才返回 true
|
||||||
|
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let with_scheme = format!("http://{value}");
|
||||||
|
if let Ok(parsed) = url::Url::parse(&with_scheme) {
|
||||||
|
if let Some(host) = parsed.host_str() {
|
||||||
|
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// 隐藏 URL 中的敏感信息(用于日志)
|
/// 隐藏 URL 中的敏感信息(用于日志)
|
||||||
pub fn mask_url(url: &str) -> String {
|
pub fn mask_url(url: &str) -> String {
|
||||||
if let Ok(parsed) = url::Url::parse(url) {
|
if let Ok(parsed) = url::Url::parse(url) {
|
||||||
@@ -247,9 +329,109 @@ pub fn mask_url(url: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 根据供应商单独代理配置构建代理 URL
|
||||||
|
///
|
||||||
|
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
|
||||||
|
fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||||
|
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
|
||||||
|
let host = config.proxy_host.as_deref()?;
|
||||||
|
let port = config.proxy_port?;
|
||||||
|
|
||||||
|
// 构建带认证的代理 URL
|
||||||
|
if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) {
|
||||||
|
if !username.is_empty() && !password.is_empty() {
|
||||||
|
return Some(format!(
|
||||||
|
"{proxy_type}://{username}:{password}@{host}:{port}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(format!("{proxy_type}://{host}:{port}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据供应商单独代理配置构建 HTTP 客户端
|
||||||
|
///
|
||||||
|
/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端;
|
||||||
|
/// 否则返回 None,调用方应使用全局客户端。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `proxy_config` - 供应商的代理配置
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// 如果配置有效则返回 Some(Client),否则返回 None
|
||||||
|
pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option<Client> {
|
||||||
|
let config = proxy_config.filter(|c| c.enabled)?;
|
||||||
|
|
||||||
|
let proxy_url = build_proxy_url_from_config(config)?;
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"[ProviderProxy] Building client with proxy: {}",
|
||||||
|
mask_url(&proxy_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 构建带代理的客户端
|
||||||
|
let proxy = match reqwest::Proxy::all(&proxy_url) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"[ProviderProxy] Failed to create proxy from '{}': {}",
|
||||||
|
mask_url(&proxy_url),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match Client::builder()
|
||||||
|
.timeout(Duration::from_secs(600))
|
||||||
|
.connect_timeout(Duration::from_secs(30))
|
||||||
|
.pool_max_idle_per_host(10)
|
||||||
|
.tcp_keepalive(Duration::from_secs(60))
|
||||||
|
.proxy(proxy)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => {
|
||||||
|
log::info!(
|
||||||
|
"[ProviderProxy] Client built with proxy: {}",
|
||||||
|
mask_url(&proxy_url)
|
||||||
|
);
|
||||||
|
Some(client)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[ProviderProxy] Failed to build client: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取供应商专用的 HTTP 客户端
|
||||||
|
///
|
||||||
|
/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `proxy_config` - 供应商的代理配置
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// 返回适合该供应商的 HTTP 客户端
|
||||||
|
pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client {
|
||||||
|
// 优先使用供应商单独代理
|
||||||
|
if let Some(client) = build_client_for_provider(proxy_config) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到全局客户端
|
||||||
|
get()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mask_url() {
|
fn test_mask_url() {
|
||||||
@@ -298,4 +480,60 @@ mod tests {
|
|||||||
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
|
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
|
||||||
assert!(result.is_err(), "Should reject invalid proxy scheme");
|
assert!(result.is_err(), "Should reject invalid proxy scheme");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_proxy_points_to_loopback() {
|
||||||
|
// 设置 CC Switch 代理端口为 15721(默认值)
|
||||||
|
set_proxy_port(15721);
|
||||||
|
|
||||||
|
// 只有指向 CC Switch 自己端口的 loopback 地址才返回 true
|
||||||
|
assert!(proxy_points_to_loopback("http://127.0.0.1:15721"));
|
||||||
|
assert!(proxy_points_to_loopback("socks5://localhost:15721"));
|
||||||
|
assert!(proxy_points_to_loopback("127.0.0.1:15721"));
|
||||||
|
|
||||||
|
// 其他 loopback 端口不应该被跳过(允许使用其他本地代理工具)
|
||||||
|
assert!(!proxy_points_to_loopback("http://127.0.0.1:7890"));
|
||||||
|
assert!(!proxy_points_to_loopback("socks5://localhost:1080"));
|
||||||
|
|
||||||
|
// 非 loopback 地址不应该被跳过
|
||||||
|
assert!(!proxy_points_to_loopback("http://192.168.1.10:7890"));
|
||||||
|
assert!(!proxy_points_to_loopback("http://192.168.1.10:15721"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_system_proxy_points_to_loopback() {
|
||||||
|
let _guard = env_lock().lock().unwrap();
|
||||||
|
|
||||||
|
// 设置 CC Switch 代理端口
|
||||||
|
set_proxy_port(15721);
|
||||||
|
|
||||||
|
let keys = [
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"http_proxy",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"https_proxy",
|
||||||
|
"ALL_PROXY",
|
||||||
|
"all_proxy",
|
||||||
|
];
|
||||||
|
|
||||||
|
for key in &keys {
|
||||||
|
std::env::remove_var(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指向 CC Switch 端口的代理应该被跳过
|
||||||
|
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:15721");
|
||||||
|
assert!(system_proxy_points_to_loopback());
|
||||||
|
|
||||||
|
// 指向其他端口的本地代理不应该被跳过
|
||||||
|
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
|
||||||
|
assert!(!system_proxy_points_to_loopback());
|
||||||
|
|
||||||
|
// 非 loopback 地址不应该被跳过
|
||||||
|
std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890");
|
||||||
|
assert!(!system_proxy_points_to_loopback());
|
||||||
|
|
||||||
|
for key in &keys {
|
||||||
|
std::env::remove_var(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
//!
|
//!
|
||||||
//! 负责选择和管理代理目标供应商,实现智能故障转移
|
//! 负责选择和管理代理目标供应商,实现智能故障转移
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
|
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ impl ProviderRouter {
|
|||||||
///
|
///
|
||||||
/// 返回按优先级排序的可用供应商列表:
|
/// 返回按优先级排序的可用供应商列表:
|
||||||
/// - 故障转移关闭时:仅返回当前供应商
|
/// - 故障转移关闭时:仅返回当前供应商
|
||||||
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
/// - 故障转移开启时:仅使用故障转移队列,按队列顺序依次尝试(P1 → P2 → ...)
|
||||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut total_providers = 0usize;
|
let mut total_providers = 0usize;
|
||||||
@@ -47,12 +49,25 @@ impl ProviderRouter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if auto_failover_enabled {
|
if auto_failover_enabled {
|
||||||
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
// 故障转移开启:仅按队列顺序依次尝试(P1 → P2 → ...)
|
||||||
let failover_providers = self.db.get_failover_providers(app_type)?;
|
let all_providers = self.db.get_all_providers(app_type)?;
|
||||||
total_providers = failover_providers.len();
|
|
||||||
|
|
||||||
for provider in failover_providers {
|
// 使用 DAO 返回的排序结果,确保和前端展示一致
|
||||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
let ordered_ids: Vec<String> = self
|
||||||
|
.db
|
||||||
|
.get_failover_queue(app_type)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| item.provider_id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
total_providers = ordered_ids.len();
|
||||||
|
|
||||||
|
for provider_id in ordered_ids {
|
||||||
|
let Some(provider) = all_providers.get(&provider_id).cloned() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let circuit_key = format!("{app_type}:{}", provider.id);
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
|
||||||
if breaker.is_available().await {
|
if breaker.is_available().await {
|
||||||
@@ -63,7 +78,16 @@ impl ProviderRouter {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
|
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
|
||||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
let current_id = AppType::from_str(app_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|app_enum| {
|
||||||
|
crate::settings::get_effective_current_provider(&self.db, &app_enum)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
})
|
||||||
|
.or_else(|| self.db.get_current_provider(app_type).ok().flatten());
|
||||||
|
|
||||||
|
if let Some(current_id) = current_id {
|
||||||
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||||
total_providers = 1;
|
total_providers = 1;
|
||||||
result.push(current);
|
result.push(current);
|
||||||
@@ -239,9 +263,53 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::env;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
struct TempHome {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
dir: TempDir,
|
||||||
|
original_home: Option<String>,
|
||||||
|
original_userprofile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempHome {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = TempDir::new().expect("failed to create temp home");
|
||||||
|
let original_home = env::var("HOME").ok();
|
||||||
|
let original_userprofile = env::var("USERPROFILE").ok();
|
||||||
|
|
||||||
|
env::set_var("HOME", dir.path());
|
||||||
|
env::set_var("USERPROFILE", dir.path());
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
original_home,
|
||||||
|
original_userprofile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempHome {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match &self.original_home {
|
||||||
|
Some(value) => env::set_var("HOME", value),
|
||||||
|
None => env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match &self.original_userprofile {
|
||||||
|
Some(value) => env::set_var("USERPROFILE", value),
|
||||||
|
None => env::remove_var("USERPROFILE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_provider_router_creation() {
|
async fn test_provider_router_creation() {
|
||||||
|
let _home = TempHome::new();
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
let router = ProviderRouter::new(db);
|
let router = ProviderRouter::new(db);
|
||||||
|
|
||||||
@@ -250,7 +318,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_failover_disabled_uses_current_provider() {
|
async fn test_failover_disabled_uses_current_provider() {
|
||||||
|
let _home = TempHome::new();
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
let provider_a =
|
let provider_a =
|
||||||
@@ -271,7 +341,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_failover_enabled_uses_queue_order() {
|
#[serial]
|
||||||
|
async fn test_failover_enabled_uses_queue_order_ignoring_current() {
|
||||||
|
let _home = TempHome::new();
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
// 设置 sort_index 来控制顺序:b=1, a=2
|
// 设置 sort_index 来控制顺序:b=1, a=2
|
||||||
@@ -298,13 +370,45 @@ mod tests {
|
|||||||
let providers = router.select_providers("claude").await.unwrap();
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
assert_eq!(providers.len(), 2);
|
assert_eq!(providers.len(), 2);
|
||||||
// 按 sort_index 排序:b(1) 在前,a(2) 在后
|
// 故障转移开启时:仅按队列顺序选择(忽略当前供应商)
|
||||||
assert_eq!(providers[0].id, "b");
|
assert_eq!(providers[0].id, "b");
|
||||||
assert_eq!(providers[1].id, "a");
|
assert_eq!(providers[1].id, "a");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_failover_enabled_uses_queue_only_even_if_current_not_in_queue() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
let provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
let mut provider_b =
|
||||||
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
provider_b.sort_index = Some(1);
|
||||||
|
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
|
|
||||||
|
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
|
||||||
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
|
||||||
|
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||||
|
config.auto_failover_enabled = true;
|
||||||
|
db.update_proxy_config_for_app(config).await.unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(providers.len(), 1);
|
||||||
|
assert_eq!(providers[0].id, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_select_providers_does_not_consume_half_open_permit() {
|
async fn test_select_providers_does_not_consume_half_open_permit() {
|
||||||
|
let _home = TempHome::new();
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||||
@@ -345,7 +449,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_release_permit_neutral_frees_half_open_slot() {
|
async fn test_release_permit_neutral_frees_half_open_slot() {
|
||||||
|
let _home = TempHome::new();
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
|
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ impl ProviderType {
|
|||||||
}
|
}
|
||||||
ProviderType::Gemini
|
ProviderType::Gemini
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy, but return a default type for completeness
|
||||||
|
ProviderType::Codex // Fallback to Codex-like type
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +180,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
|||||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy, fallback to Codex adapter
|
||||||
|
Box::new(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;
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ pub fn create_anthropic_sse_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
||||||
// 仅在 DEBUG 级别简短记录 SSE 事件
|
|
||||||
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
|
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
|
||||||
|
|
||||||
if message_id.is_none() {
|
if message_id.is_none() {
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ use super::{
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::stream::{Stream, StreamExt};
|
use futures::stream::{Stream, StreamExt};
|
||||||
use rust_decimal::Decimal;
|
use reqwest::header::HeaderMap;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::{
|
use std::{
|
||||||
str::FromStr,
|
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
@@ -47,6 +46,12 @@ pub async fn handle_streaming(
|
|||||||
parser_config: &UsageParserConfig,
|
parser_config: &UsageParserConfig,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
log::debug!(
|
||||||
|
"[{}] 已接收上游流式响应: status={}, headers={}",
|
||||||
|
ctx.tag,
|
||||||
|
status.as_u16(),
|
||||||
|
format_headers(response.headers())
|
||||||
|
);
|
||||||
let mut builder = axum::response::Response::builder().status(status);
|
let mut builder = axum::response::Response::builder().status(status);
|
||||||
|
|
||||||
// 复制响应头
|
// 复制响应头
|
||||||
@@ -94,6 +99,19 @@ pub async fn handle_non_streaming(
|
|||||||
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
|
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
|
||||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
log::debug!(
|
||||||
|
"[{}] 已接收上游响应体: status={}, bytes={}, headers={}",
|
||||||
|
ctx.tag,
|
||||||
|
status.as_u16(),
|
||||||
|
body_bytes.len(),
|
||||||
|
format_headers(&response_headers)
|
||||||
|
);
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"[{}] 上游响应体内容: {}",
|
||||||
|
ctx.tag,
|
||||||
|
String::from_utf8_lossy(&body_bytes)
|
||||||
|
);
|
||||||
|
|
||||||
// 解析并记录使用量
|
// 解析并记录使用量
|
||||||
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
||||||
@@ -108,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")
|
||||||
@@ -120,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,
|
||||||
);
|
);
|
||||||
@@ -139,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,
|
||||||
);
|
);
|
||||||
@@ -273,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(
|
||||||
@@ -280,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,
|
||||||
@@ -295,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(
|
||||||
@@ -302,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,
|
||||||
@@ -322,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,
|
||||||
) {
|
) {
|
||||||
@@ -329,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();
|
||||||
|
|
||||||
@@ -338,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,
|
||||||
@@ -356,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>,
|
||||||
@@ -366,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();
|
||||||
@@ -404,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,
|
||||||
@@ -470,6 +494,12 @@ pub fn create_logged_passthrough_stream(
|
|||||||
|
|
||||||
match chunk_result {
|
match chunk_result {
|
||||||
Some(Ok(bytes)) => {
|
Some(Ok(bytes)) => {
|
||||||
|
if is_first_chunk {
|
||||||
|
log::debug!(
|
||||||
|
"[{tag}] 已接收上游流式首包: bytes={}",
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
is_first_chunk = false;
|
is_first_chunk = false;
|
||||||
let text = String::from_utf8_lossy(&bytes);
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
buffer.push_str(&text);
|
buffer.push_str(&text);
|
||||||
@@ -488,13 +518,9 @@ pub fn create_logged_passthrough_stream(
|
|||||||
if let Some(c) = &collector {
|
if let Some(c) = &collector {
|
||||||
c.push(json_value.clone()).await;
|
c.push(json_value.clone()).await;
|
||||||
}
|
}
|
||||||
log::debug!(
|
log::debug!("[{tag}] <<< SSE 事件: {data}");
|
||||||
"[{}] <<< SSE 事件: {}",
|
|
||||||
tag,
|
|
||||||
data.chars().take(100).collect::<String>()
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::<String>());
|
log::debug!("[{tag}] <<< SSE 数据: {data}");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::debug!("[{tag}] <<< SSE: [DONE]");
|
log::debug!("[{tag}] <<< SSE: [DONE]");
|
||||||
@@ -523,3 +549,196 @@ pub fn create_logged_passthrough_stream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_headers(headers: &HeaderMap) -> String {
|
||||||
|
headers
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| {
|
||||||
|
let value_str = value.to_str().unwrap_or("<non-utf8>");
|
||||||
|
format!("{key}={value_str}")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::ProviderMeta;
|
||||||
|
use crate::proxy::failover_switch::FailoverSwitchManager;
|
||||||
|
use crate::proxy::provider_router::ProviderRouter;
|
||||||
|
use crate::proxy::types::{ProxyConfig, ProxyStatus};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
fn build_state(db: Arc<Database>) -> ProxyState {
|
||||||
|
ProxyState {
|
||||||
|
db: db.clone(),
|
||||||
|
config: Arc::new(RwLock::new(ProxyConfig::default())),
|
||||||
|
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||||
|
start_time: Arc::new(RwLock::new(None)),
|
||||||
|
current_providers: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
provider_router: Arc::new(ProviderRouter::new(db.clone())),
|
||||||
|
app_handle: None,
|
||||||
|
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_pricing(db: &Database) -> Result<(), AppError> {
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
rusqlite::params!["resp-model", "Resp Model", "1.0", "0"],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
rusqlite::params!["req-model", "Req Model", "2.0", "0"],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_provider(
|
||||||
|
db: &Database,
|
||||||
|
id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
meta: ProviderMeta,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let meta_json =
|
||||||
|
serde_json::to_string(&meta).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
rusqlite::params![id, app_type, "Test Provider", "{}", meta_json],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_log_usage_uses_provider_override_config() -> Result<(), AppError> {
|
||||||
|
let db = Arc::new(Database::memory()?);
|
||||||
|
let app_type = "claude";
|
||||||
|
|
||||||
|
db.set_default_cost_multiplier(app_type, "1.5").await?;
|
||||||
|
db.set_pricing_model_source(app_type, "response").await?;
|
||||||
|
seed_pricing(&db)?;
|
||||||
|
|
||||||
|
let mut meta = ProviderMeta::default();
|
||||||
|
meta.cost_multiplier = Some("2".to_string());
|
||||||
|
meta.pricing_model_source = Some("request".to_string());
|
||||||
|
insert_provider(&db, "provider-1", app_type, meta)?;
|
||||||
|
|
||||||
|
let state = build_state(db.clone());
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1_000_000,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
log_usage_internal(
|
||||||
|
&state,
|
||||||
|
"provider-1",
|
||||||
|
app_type,
|
||||||
|
"resp-model",
|
||||||
|
"req-model",
|
||||||
|
usage,
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
200,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let (model, request_model, total_cost, cost_multiplier): (String, String, String, String) =
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT model, request_model, total_cost_usd, cost_multiplier
|
||||||
|
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||||
|
["provider-1"],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
assert_eq!(model, "resp-model");
|
||||||
|
assert_eq!(request_model, "req-model");
|
||||||
|
assert_eq!(
|
||||||
|
Decimal::from_str(&cost_multiplier).unwrap(),
|
||||||
|
Decimal::from_str("2").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Decimal::from_str(&total_cost).unwrap(),
|
||||||
|
Decimal::from_str("4").unwrap()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
|
||||||
|
let db = Arc::new(Database::memory()?);
|
||||||
|
let app_type = "claude";
|
||||||
|
|
||||||
|
db.set_default_cost_multiplier(app_type, "1.5").await?;
|
||||||
|
db.set_pricing_model_source(app_type, "response").await?;
|
||||||
|
seed_pricing(&db)?;
|
||||||
|
|
||||||
|
let meta = ProviderMeta::default();
|
||||||
|
insert_provider(&db, "provider-2", app_type, meta)?;
|
||||||
|
|
||||||
|
let state = build_state(db.clone());
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1_000_000,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
log_usage_internal(
|
||||||
|
&state,
|
||||||
|
"provider-2",
|
||||||
|
app_type,
|
||||||
|
"resp-model",
|
||||||
|
"req-model",
|
||||||
|
usage,
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
200,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let (total_cost, cost_multiplier): (String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT total_cost_usd, cost_multiplier
|
||||||
|
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||||
|
["provider-2"],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Decimal::from_str(&cost_multiplier).unwrap(),
|
||||||
|
Decimal::from_str("1.5").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Decimal::from_str(&total_cost).unwrap(),
|
||||||
|
Decimal::from_str("1.5").unwrap()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use super::{
|
|||||||
};
|
};
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
@@ -97,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);
|
||||||
|
|
||||||
@@ -189,6 +193,18 @@ impl ProxyServer {
|
|||||||
status
|
status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新某个应用类型当前“目标供应商”(用于 UI 展示 active_targets)
|
||||||
|
///
|
||||||
|
/// 注意:这不代表该供应商一定已经处理过请求,而是用于“热切换/启用故障转移立即切 P1”
|
||||||
|
/// 等场景下,让 UI 能立刻反映最新目标。
|
||||||
|
pub async fn set_active_target(&self, app_type: &str, provider_id: &str, provider_name: &str) {
|
||||||
|
let mut current_providers = self.state.current_providers.write().await;
|
||||||
|
current_providers.insert(
|
||||||
|
app_type.to_string(),
|
||||||
|
(provider_id.to_string(), provider_name.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn build_router(&self) -> Router {
|
fn build_router(&self) -> Router {
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -224,6 +240,8 @@ impl ProxyServer {
|
|||||||
// Gemini API (支持带前缀和不带前缀)
|
// Gemini API (支持带前缀和不带前缀)
|
||||||
.route("/v1beta/*path", post(handlers::handle_gemini))
|
.route("/v1beta/*path", post(handlers::handle_gemini))
|
||||||
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
||||||
|
// 提高默认请求体大小限制(避免 413 Payload Too Large)
|
||||||
|
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(self.state.clone())
|
.with_state(self.state.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-28
@@ -16,13 +16,13 @@ pub struct ProxyConfig {
|
|||||||
/// 是否正在接管 Live 配置
|
/// 是否正在接管 Live 配置
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub live_takeover_active: bool,
|
pub live_takeover_active: bool,
|
||||||
/// 流式首字超时(秒)- 等待首个数据块的最大时间
|
/// 流式首字超时(秒)- 等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒
|
||||||
#[serde(default = "default_streaming_first_byte_timeout")]
|
#[serde(default = "default_streaming_first_byte_timeout")]
|
||||||
pub streaming_first_byte_timeout: u64,
|
pub streaming_first_byte_timeout: u64,
|
||||||
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
|
/// 流式静默超时(秒)- 两个数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住)
|
||||||
#[serde(default = "default_streaming_idle_timeout")]
|
#[serde(default = "default_streaming_idle_timeout")]
|
||||||
pub streaming_idle_timeout: u64,
|
pub streaming_idle_timeout: u64,
|
||||||
/// 非流式总超时(秒)- 非流式请求的总超时时间
|
/// 非流式总超时(秒)- 非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟)
|
||||||
#[serde(default = "default_non_streaming_timeout")]
|
#[serde(default = "default_non_streaming_timeout")]
|
||||||
pub non_streaming_timeout: u64,
|
pub non_streaming_timeout: u64,
|
||||||
}
|
}
|
||||||
@@ -195,63 +195,171 @@ pub struct AppProxyConfig {
|
|||||||
/// 整流器配置
|
/// 整流器配置
|
||||||
///
|
///
|
||||||
/// 存储在 settings 表中
|
/// 存储在 settings 表中
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RectifierConfig {
|
pub struct RectifierConfig {
|
||||||
/// 总开关:是否启用整流器
|
/// 总开关:是否启用整流器
|
||||||
#[serde(default = "default_true")]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// 请求整流:启用 thinking 签名整流器
|
/// 请求整流:启用 thinking 签名整流器
|
||||||
///
|
///
|
||||||
/// 处理错误:Invalid 'signature' in 'thinking' block
|
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||||
#[serde(default = "default_true")]
|
#[serde(default)]
|
||||||
pub request_thinking_signature: bool,
|
pub request_thinking_signature: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RectifierConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: true,
|
|
||||||
request_thinking_signature: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_log_level() -> String {
|
||||||
|
"info".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志配置
|
||||||
|
///
|
||||||
|
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LogConfig {
|
||||||
|
/// 总开关:是否启用日志
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 日志级别: error, warn, info, debug, trace
|
||||||
|
#[serde(default = "default_log_level")]
|
||||||
|
pub level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LogConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
level: "info".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogConfig {
|
||||||
|
/// 将配置转换为 log::LevelFilter
|
||||||
|
pub fn to_level_filter(&self) -> log::LevelFilter {
|
||||||
|
if !self.enabled {
|
||||||
|
return log::LevelFilter::Off;
|
||||||
|
}
|
||||||
|
match self.level.to_lowercase().as_str() {
|
||||||
|
"error" => log::LevelFilter::Error,
|
||||||
|
"warn" => log::LevelFilter::Warn,
|
||||||
|
"info" => log::LevelFilter::Info,
|
||||||
|
"debug" => log::LevelFilter::Debug,
|
||||||
|
"trace" => log::LevelFilter::Trace,
|
||||||
|
_ => log::LevelFilter::Info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rectifier_config_default_enabled() {
|
fn test_rectifier_config_default_disabled() {
|
||||||
// 验证 RectifierConfig::default() 返回全启用状态
|
// 验证 RectifierConfig::default() 返回全禁用状态
|
||||||
// 防止回归:#[derive(Default)] 会使 bool 默认为 false
|
|
||||||
let config = RectifierConfig::default();
|
let config = RectifierConfig::default();
|
||||||
assert!(config.enabled, "整流器总开关默认应为 true");
|
assert!(!config.enabled, "整流器总开关默认应为 false");
|
||||||
assert!(
|
assert!(
|
||||||
config.request_thinking_signature,
|
!config.request_thinking_signature,
|
||||||
"thinking 签名整流器默认应为 true"
|
"thinking 签名整流器默认应为 false"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rectifier_config_serde_default() {
|
fn test_rectifier_config_serde_default() {
|
||||||
// 验证反序列化缺字段时使用 default_true
|
// 验证反序列化缺字段时使用默认值 false
|
||||||
let json = "{}";
|
let json = "{}";
|
||||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!config.enabled);
|
||||||
|
assert!(!config.request_thinking_signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectifier_config_serde_explicit_true() {
|
||||||
|
// 验证显式设置 true 时正确反序列化
|
||||||
|
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
|
||||||
|
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||||
assert!(config.enabled);
|
assert!(config.enabled);
|
||||||
assert!(config.request_thinking_signature);
|
assert!(config.request_thinking_signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rectifier_config_serde_explicit_false() {
|
fn test_log_config_default() {
|
||||||
// 验证显式设置 false 时正确反序列化
|
let config = LogConfig::default();
|
||||||
let json = r#"{"enabled": false, "requestThinkingSignature": false}"#;
|
assert!(config.enabled);
|
||||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
assert_eq!(config.level, "info");
|
||||||
assert!(!config.enabled);
|
}
|
||||||
assert!(!config.request_thinking_signature);
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_config_serde_default() {
|
||||||
|
let json = "{}";
|
||||||
|
let config: LogConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert_eq!(config.level, "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_config_to_level_filter() {
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "error".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Error);
|
||||||
|
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "warn".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Warn);
|
||||||
|
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "info".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Info);
|
||||||
|
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "debug".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Debug);
|
||||||
|
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "trace".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Trace);
|
||||||
|
|
||||||
|
// 无效级别回退到 info
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "invalid".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Info);
|
||||||
|
|
||||||
|
// 禁用时返回 Off
|
||||||
|
let config = LogConfig {
|
||||||
|
enabled: false,
|
||||||
|
level: "debug".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Off);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_config_serde_roundtrip() {
|
||||||
|
let config = LogConfig {
|
||||||
|
enabled: true,
|
||||||
|
level: "debug".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let parsed: LogConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(parsed.enabled);
|
||||||
|
assert_eq!(parsed.level, "debug");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ impl CostCalculator {
|
|||||||
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
|
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
|
||||||
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
|
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
|
||||||
/// - 这样避免缓存部分被重复计费
|
/// - 这样避免缓存部分被重复计费
|
||||||
|
/// - total_cost: 各项成本之和 × 倍率(倍率只作用于最终总价)
|
||||||
pub fn calculate(
|
pub fn calculate(
|
||||||
usage: &TokenUsage,
|
usage: &TokenUsage,
|
||||||
pricing: &ModelPricing,
|
pricing: &ModelPricing,
|
||||||
@@ -50,21 +51,20 @@ impl CostCalculator {
|
|||||||
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
|
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
|
||||||
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
|
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
|
||||||
|
|
||||||
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
|
// 各项基础成本(不含倍率)
|
||||||
/ million
|
let input_cost =
|
||||||
* cost_multiplier;
|
Decimal::from(billable_input_tokens) * pricing.input_cost_per_million / million;
|
||||||
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
|
let output_cost =
|
||||||
/ million
|
Decimal::from(usage.output_tokens) * pricing.output_cost_per_million / million;
|
||||||
* cost_multiplier;
|
|
||||||
let cache_read_cost =
|
let cache_read_cost =
|
||||||
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million
|
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million;
|
||||||
* cost_multiplier;
|
|
||||||
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
|
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
|
||||||
* pricing.cache_creation_cost_per_million
|
* pricing.cache_creation_cost_per_million
|
||||||
/ million
|
/ million;
|
||||||
* cost_multiplier;
|
|
||||||
|
|
||||||
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
// 总成本 = 各项基础成本之和 × 倍率
|
||||||
|
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||||||
|
let total_cost = base_total * cost_multiplier;
|
||||||
|
|
||||||
CostBreakdown {
|
CostBreakdown {
|
||||||
input_cost,
|
input_cost,
|
||||||
@@ -151,8 +151,9 @@ mod tests {
|
|||||||
|
|
||||||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||||
|
|
||||||
// input: 1000 * 3.0 / 1M * 1.5 = 0.0045
|
// input_cost: 基础价格(不含倍率)= 1000 * 3.0 / 1M = 0.003
|
||||||
assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap());
|
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
|
||||||
|
// total_cost: 基础价格 × 倍率 = 0.003 * 1.5 = 0.0045
|
||||||
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
|
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::database::Database;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::usage_stats::find_model_pricing_row;
|
use crate::services::usage_stats::find_model_pricing_row;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use std::time::SystemTime;
|
use std::{str::FromStr, time::SystemTime};
|
||||||
|
|
||||||
/// 请求日志
|
/// 请求日志
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -15,6 +15,7 @@ pub struct RequestLog {
|
|||||||
pub provider_id: String,
|
pub provider_id: String,
|
||||||
pub app_type: String,
|
pub app_type: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
|
pub request_model: String,
|
||||||
pub usage: TokenUsage,
|
pub usage: TokenUsage,
|
||||||
pub cost: Option<CostBreakdown>,
|
pub cost: Option<CostBreakdown>,
|
||||||
pub latency_ms: u64,
|
pub latency_ms: u64,
|
||||||
@@ -73,17 +74,18 @@ impl<'a> UsageLogger<'a> {
|
|||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO proxy_request_logs (
|
"INSERT INTO proxy_request_logs (
|
||||||
request_id, provider_id, app_type, model,
|
request_id, provider_id, app_type, model, request_model,
|
||||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||||
provider_type, is_streaming, cost_multiplier, created_at
|
provider_type, is_streaming, cost_multiplier, created_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
log.request_id,
|
log.request_id,
|
||||||
log.provider_id,
|
log.provider_id,
|
||||||
log.app_type,
|
log.app_type,
|
||||||
log.model,
|
log.model,
|
||||||
|
log.request_model,
|
||||||
log.usage.input_tokens,
|
log.usage.input_tokens,
|
||||||
log.usage.output_tokens,
|
log.usage.output_tokens,
|
||||||
log.usage.cache_read_tokens,
|
log.usage.cache_read_tokens,
|
||||||
@@ -123,11 +125,13 @@ impl<'a> UsageLogger<'a> {
|
|||||||
error_message: String,
|
error_message: String,
|
||||||
latency_ms: u64,
|
latency_ms: u64,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
|
let request_model = model.clone();
|
||||||
let log = RequestLog {
|
let log = RequestLog {
|
||||||
request_id,
|
request_id,
|
||||||
provider_id,
|
provider_id,
|
||||||
app_type,
|
app_type,
|
||||||
model,
|
model,
|
||||||
|
request_model,
|
||||||
usage: TokenUsage::default(),
|
usage: TokenUsage::default(),
|
||||||
cost: None,
|
cost: None,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
@@ -160,11 +164,13 @@ impl<'a> UsageLogger<'a> {
|
|||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
provider_type: Option<String>,
|
provider_type: Option<String>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
|
let request_model = model.clone();
|
||||||
let log = RequestLog {
|
let log = RequestLog {
|
||||||
request_id,
|
request_id,
|
||||||
provider_id,
|
provider_id,
|
||||||
app_type,
|
app_type,
|
||||||
model,
|
model,
|
||||||
|
request_model,
|
||||||
usage: TokenUsage::default(),
|
usage: TokenUsage::default(),
|
||||||
cost: None,
|
cost: None,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
@@ -194,6 +200,88 @@ impl<'a> UsageLogger<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取有效的倍率与计费模式来源(供应商优先,未配置则回退全局默认)
|
||||||
|
pub async fn resolve_pricing_config(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
) -> (Decimal, String) {
|
||||||
|
let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
|
||||||
|
"1".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"[USG-003] 默认倍率解析失败 (app_type={app_type}): {default_multiplier_raw} - {e}"
|
||||||
|
);
|
||||||
|
Decimal::from(1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
|
||||||
|
"response".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let default_pricing_source =
|
||||||
|
if matches!(default_pricing_source_raw.as_str(), "response" | "request") {
|
||||||
|
default_pricing_source_raw
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"[USG-003] 默认计费模式无效 (app_type={app_type}): {default_pricing_source_raw}"
|
||||||
|
);
|
||||||
|
"response".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = self
|
||||||
|
.db
|
||||||
|
.get_provider_by_id(provider_id, app_type)
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let (provider_multiplier, provider_pricing_source) = provider
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.meta.as_ref())
|
||||||
|
.map(|meta| {
|
||||||
|
(
|
||||||
|
meta.cost_multiplier.as_deref(),
|
||||||
|
meta.pricing_model_source.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
|
let cost_multiplier = match provider_multiplier {
|
||||||
|
Some(value) => match Decimal::from_str(value) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"[USG-003] 供应商倍率解析失败 (provider_id={provider_id}): {value} - {e}"
|
||||||
|
);
|
||||||
|
default_multiplier
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => default_multiplier,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pricing_model_source = match provider_pricing_source {
|
||||||
|
Some(value) if matches!(value, "response" | "request") => value.to_string(),
|
||||||
|
Some(value) => {
|
||||||
|
log::warn!("[USG-003] 供应商计费模式无效 (provider_id={provider_id}): {value}");
|
||||||
|
default_pricing_source.clone()
|
||||||
|
}
|
||||||
|
None => default_pricing_source.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(cost_multiplier, pricing_model_source)
|
||||||
|
}
|
||||||
|
|
||||||
/// 计算并记录请求
|
/// 计算并记录请求
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn log_with_calculation(
|
pub fn log_with_calculation(
|
||||||
@@ -202,6 +290,8 @@ impl<'a> UsageLogger<'a> {
|
|||||||
provider_id: String,
|
provider_id: String,
|
||||||
app_type: String,
|
app_type: String,
|
||||||
model: String,
|
model: String,
|
||||||
|
request_model: String,
|
||||||
|
pricing_model: String,
|
||||||
usage: TokenUsage,
|
usage: TokenUsage,
|
||||||
cost_multiplier: Decimal,
|
cost_multiplier: Decimal,
|
||||||
latency_ms: u64,
|
latency_ms: u64,
|
||||||
@@ -211,10 +301,10 @@ impl<'a> UsageLogger<'a> {
|
|||||||
provider_type: Option<String>,
|
provider_type: Option<String>,
|
||||||
is_streaming: bool,
|
is_streaming: bool,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let pricing = self.get_model_pricing(&model)?;
|
let pricing = self.get_model_pricing(&pricing_model)?;
|
||||||
|
|
||||||
if pricing.is_none() {
|
if pricing.is_none() {
|
||||||
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
|
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
||||||
@@ -224,6 +314,7 @@ impl<'a> UsageLogger<'a> {
|
|||||||
provider_id,
|
provider_id,
|
||||||
app_type,
|
app_type,
|
||||||
model,
|
model,
|
||||||
|
request_model,
|
||||||
usage,
|
usage,
|
||||||
cost,
|
cost,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
@@ -274,6 +365,8 @@ mod tests {
|
|||||||
"provider-1".to_string(),
|
"provider-1".to_string(),
|
||||||
"claude".to_string(),
|
"claude".to_string(),
|
||||||
"test-model".to_string(),
|
"test-model".to_string(),
|
||||||
|
"req-model".to_string(),
|
||||||
|
"test-model".to_string(),
|
||||||
usage,
|
usage,
|
||||||
Decimal::from(1),
|
Decimal::from(1),
|
||||||
100,
|
100,
|
||||||
@@ -286,14 +379,15 @@ mod tests {
|
|||||||
|
|
||||||
// 验证记录已插入
|
// 验证记录已插入
|
||||||
let conn = crate::database::lock_conn!(db.conn);
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
let count: i64 = conn
|
let (count, request_model): (i64, String) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'req-123'",
|
"SELECT COUNT(*), request_model FROM proxy_request_logs WHERE request_id = 'req-123'",
|
||||||
[],
|
[],
|
||||||
|row| row.get(0),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
|
assert_eq!(request_model, "req-model");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::provider::ProviderService;
|
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
|
||||||
use crate::app_config::{AppType, MultiAppConfig};
|
use crate::app_config::{AppType, MultiAppConfig};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -122,6 +122,10 @@ impl ConfigService {
|
|||||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses additive mode, no live sync needed
|
||||||
|
// OpenCode providers are managed directly in the config file
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -177,7 +181,8 @@ impl ConfigService {
|
|||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
write_json_file(&settings_path, &provider.settings_config)?;
|
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
||||||
|
write_json_file(&settings_path, &settings)?;
|
||||||
|
|
||||||
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ impl McpService {
|
|||||||
if prev_apps.gemini && !server.apps.gemini {
|
if prev_apps.gemini && !server.apps.gemini {
|
||||||
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
||||||
}
|
}
|
||||||
|
if prev_apps.opencode && !server.apps.opencode {
|
||||||
|
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
|
||||||
|
}
|
||||||
|
|
||||||
// 同步到各个启用的应用
|
// 同步到各个启用的应用
|
||||||
Self::sync_server_to_apps(state, &server)?;
|
Self::sync_server_to_apps(state, &server)?;
|
||||||
@@ -113,6 +116,13 @@ impl McpService {
|
|||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
mcp::sync_single_server_to_opencode(
|
||||||
|
&Default::default(),
|
||||||
|
&server.id,
|
||||||
|
&server.server,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -135,6 +145,9 @@ impl McpService {
|
|||||||
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
||||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||||
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||||
|
AppType::OpenCode => {
|
||||||
|
mcp::remove_server_from_opencode(id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -311,4 +324,42 @@ impl McpService {
|
|||||||
|
|
||||||
Ok(new_count)
|
Ok(new_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 OpenCode 导入 MCP(v3.9.2+ 新增)
|
||||||
|
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
// 创建临时 MultiAppConfig 用于导入
|
||||||
|
let mut temp_config = crate::app_config::MultiAppConfig::default();
|
||||||
|
|
||||||
|
// 调用原有的导入逻辑(从 mcp/opencode.rs)
|
||||||
|
let count = crate::mcp::import_from_opencode(&mut temp_config)?;
|
||||||
|
|
||||||
|
let mut new_count = 0;
|
||||||
|
|
||||||
|
// 如果有导入的服务器,保存到数据库
|
||||||
|
if count > 0 {
|
||||||
|
if let Some(servers) = &temp_config.mcp.servers {
|
||||||
|
let mut existing = state.db.get_all_mcp_servers()?;
|
||||||
|
for server in servers.values() {
|
||||||
|
// 已存在:仅启用 OpenCode,不覆盖其他字段(与导入模块语义保持一致)
|
||||||
|
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||||
|
let mut merged = existing_server.clone();
|
||||||
|
merged.apps.opencode = true;
|
||||||
|
merged
|
||||||
|
} else {
|
||||||
|
// 真正的新服务器
|
||||||
|
new_count += 1;
|
||||||
|
server.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.db.save_mcp_server(&to_save)?;
|
||||||
|
existing.insert(to_save.id.clone(), to_save.clone());
|
||||||
|
|
||||||
|
// 同步到对应应用 live 配置
|
||||||
|
Self::sync_server_to_apps(state, &to_save)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(new_count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ pub mod proxy;
|
|||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod speedtest;
|
pub mod speedtest;
|
||||||
pub mod stream_check;
|
pub mod stream_check;
|
||||||
pub mod template;
|
|
||||||
pub mod usage_stats;
|
pub mod usage_stats;
|
||||||
|
|
||||||
pub use config::ConfigService;
|
pub use config::ConfigService;
|
||||||
@@ -20,12 +19,6 @@ pub use proxy::ProxyService;
|
|||||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use template::{
|
|
||||||
BatchInstallResult, ComponentDetail, ComponentMetadata, ComponentType, InstalledComponent,
|
|
||||||
MarketplaceBundle, MarketplaceBundleItem, PaginatedResult, TemplateComponent, TemplateRepo,
|
|
||||||
TemplateService,
|
|
||||||
};
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use usage_stats::{
|
pub use usage_stats::{
|
||||||
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
||||||
RequestLogDetail, UsageSummary,
|
RequestLogDetail, UsageSummary,
|
||||||
|
|||||||
@@ -36,10 +36,22 @@ impl PromptService {
|
|||||||
|
|
||||||
state.db.save_prompt(app.as_str(), &prompt)?;
|
state.db.save_prompt(app.as_str(), &prompt)?;
|
||||||
|
|
||||||
// 如果是已启用的提示词,同步更新到对应的文件
|
|
||||||
if is_enabled {
|
if is_enabled {
|
||||||
|
// 启用提示词:写入内容到文件
|
||||||
let target_path = prompt_file_path(&app)?;
|
let target_path = prompt_file_path(&app)?;
|
||||||
write_text_file(&target_path, &prompt.content)?;
|
write_text_file(&target_path, &prompt.content)?;
|
||||||
|
} else {
|
||||||
|
// 禁用提示词:检查是否还有其他已启用的提示词
|
||||||
|
let prompts = state.db.get_prompts(app.as_str())?;
|
||||||
|
let any_enabled = prompts.values().any(|p| p.enabled);
|
||||||
|
|
||||||
|
if !any_enabled {
|
||||||
|
// 所有提示词都已禁用,清空文件
|
||||||
|
let target_path = prompt_file_path(&app)?;
|
||||||
|
if target_path.exists() {
|
||||||
|
write_text_file(&target_path, "")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ use super::gemini_auth::{
|
|||||||
};
|
};
|
||||||
use super::normalize_claude_models_in_value;
|
use super::normalize_claude_models_in_value;
|
||||||
|
|
||||||
|
pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
|
||||||
|
let mut v = settings.clone();
|
||||||
|
if let Some(obj) = v.as_object_mut() {
|
||||||
|
// Internal-only fields - never write to Claude Code settings.json
|
||||||
|
obj.remove("api_format");
|
||||||
|
obj.remove("apiFormat");
|
||||||
|
obj.remove("openrouter_compat_mode");
|
||||||
|
obj.remove("openrouterCompatMode");
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
/// Live configuration snapshot for backup/restore
|
/// Live configuration snapshot for backup/restore
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -97,7 +109,8 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
match app_type {
|
match app_type {
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
let path = get_claude_settings_path();
|
let path = get_claude_settings_path();
|
||||||
write_json_file(&path, &provider.settings_config)?;
|
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
||||||
|
write_json_file(&path, &settings)?;
|
||||||
}
|
}
|
||||||
AppType::Codex => {
|
AppType::Codex => {
|
||||||
let obj = provider
|
let obj = provider
|
||||||
@@ -120,37 +133,129 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
// Delegate to write_gemini_live which handles env file writing correctly
|
// Delegate to write_gemini_live which handles env file writing correctly
|
||||||
write_gemini_live(provider)?;
|
write_gemini_live(provider)?;
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses additive mode - write provider to config
|
||||||
|
use crate::opencode_config;
|
||||||
|
use crate::provider::OpenCodeProviderConfig;
|
||||||
|
|
||||||
|
// Defensive check: if settings_config is a full config structure, extract provider fragment
|
||||||
|
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
|
||||||
|
// Detect full config structure (has $schema or top-level provider field)
|
||||||
|
if obj.contains_key("$schema") || obj.contains_key("provider") {
|
||||||
|
log::warn!(
|
||||||
|
"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment",
|
||||||
|
provider.id
|
||||||
|
);
|
||||||
|
// Try to extract from provider.{id}
|
||||||
|
obj.get("provider")
|
||||||
|
.and_then(|p| p.get(&provider.id))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| provider.settings_config.clone())
|
||||||
|
} else {
|
||||||
|
provider.settings_config.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
provider.settings_config.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert settings_config to OpenCodeProviderConfig
|
||||||
|
let opencode_config_result =
|
||||||
|
serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());
|
||||||
|
|
||||||
|
match opencode_config_result {
|
||||||
|
Ok(config) => {
|
||||||
|
opencode_config::set_typed_provider(&provider.id, &config)?;
|
||||||
|
log::info!("OpenCode provider '{}' written to live config", provider.id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to parse OpenCode provider config for '{}': {}",
|
||||||
|
provider.id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
// Only write if config looks like a valid provider fragment
|
||||||
|
if config_to_write.get("npm").is_some()
|
||||||
|
|| config_to_write.get("options").is_some()
|
||||||
|
{
|
||||||
|
opencode_config::set_provider(&provider.id, config_to_write)?;
|
||||||
|
log::info!(
|
||||||
|
"OpenCode provider '{}' written as raw JSON to live config",
|
||||||
|
provider.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::error!(
|
||||||
|
"OpenCode provider '{}' has invalid config structure, skipping write",
|
||||||
|
provider.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sync all providers to live configuration (for additive mode apps)
|
||||||
|
///
|
||||||
|
/// Writes all providers from the database to the live configuration file.
|
||||||
|
/// Used for OpenCode and other additive mode applications.
|
||||||
|
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
|
||||||
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
|
||||||
|
for provider in providers.values() {
|
||||||
|
if let Err(e) = write_live_snapshot(app_type, provider) {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||||
|
app_type,
|
||||||
|
provider.id
|
||||||
|
);
|
||||||
|
// Continue syncing other providers, don't abort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Synced {} {:?} providers to live config",
|
||||||
|
providers.len(),
|
||||||
|
app_type
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync current provider to live configuration
|
/// Sync current provider to live configuration
|
||||||
///
|
///
|
||||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||||
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
|
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
|
||||||
|
///
|
||||||
|
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
|
||||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
// Sync providers based on mode
|
||||||
// Use validated effective current provider
|
for app_type in AppType::all() {
|
||||||
let current_id =
|
if app_type.is_additive_mode() {
|
||||||
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
|
// Additive mode: sync ALL providers
|
||||||
Some(id) => id,
|
sync_all_providers_to_live(state, &app_type)?;
|
||||||
None => continue,
|
} else {
|
||||||
};
|
// Switch mode: sync only current provider
|
||||||
|
let current_id =
|
||||||
|
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
|
||||||
|
Some(id) => id,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
if let Some(provider) = providers.get(¤t_id) {
|
if let Some(provider) = providers.get(¤t_id) {
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_snapshot(&app_type, provider)?;
|
||||||
|
}
|
||||||
|
// Note: get_effective_current_provider already validates existence,
|
||||||
|
// so providers.get() should always succeed here
|
||||||
}
|
}
|
||||||
// Note: get_effective_current_provider already validates existence,
|
|
||||||
// so providers.get() should always succeed here
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP sync
|
// MCP sync
|
||||||
McpService::sync_all_enabled(state)?;
|
McpService::sync_all_enabled(state)?;
|
||||||
|
|
||||||
// Skill sync
|
// Skill sync
|
||||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app_type in AppType::all() {
|
||||||
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
|
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
|
||||||
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
|
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
|
||||||
// Continue syncing other apps, don't abort
|
// Continue syncing other apps, don't abort
|
||||||
@@ -220,6 +325,21 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
|||||||
"config": config_obj
|
"config": config_obj
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||||
|
|
||||||
|
let config_path = get_opencode_config_path();
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"opencode.config.missing",
|
||||||
|
"OpenCode 配置文件不存在",
|
||||||
|
"OpenCode configuration file not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = read_opencode_config()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +415,24 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
|||||||
"config": config_obj
|
"config": config_obj
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses additive mode - import from live is not the same pattern
|
||||||
|
// For now, return an empty config structure
|
||||||
|
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||||
|
|
||||||
|
let config_path = get_opencode_config_path();
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"opencode.live.missing",
|
||||||
|
"OpenCode 配置文件不存在",
|
||||||
|
"OpenCode configuration file is missing",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For OpenCode, we return the full config - but note that OpenCode
|
||||||
|
// uses additive mode, so importing defaults works differently
|
||||||
|
read_opencode_config()?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut provider = Provider::with_id(
|
let mut provider = Provider::with_id(
|
||||||
@@ -399,3 +537,75 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove an OpenCode provider from the live configuration
|
||||||
|
///
|
||||||
|
/// This is specific to OpenCode's additive mode - removing a provider
|
||||||
|
/// from the opencode.json file.
|
||||||
|
pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> {
|
||||||
|
use crate::opencode_config;
|
||||||
|
|
||||||
|
// Check if OpenCode config directory exists
|
||||||
|
if !opencode_config::get_opencode_dir().exists() {
|
||||||
|
log::debug!("OpenCode config directory doesn't exist, skipping removal of '{provider_id}'");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
opencode_config::remove_provider(provider_id)?;
|
||||||
|
log::info!("OpenCode provider '{provider_id}' removed from live config");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import all providers from OpenCode live config to database
|
||||||
|
///
|
||||||
|
/// This imports existing providers from ~/.config/opencode/opencode.json
|
||||||
|
/// into the CC Switch database. Each provider found will be added to the
|
||||||
|
/// database with is_current set to false.
|
||||||
|
pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
use crate::opencode_config;
|
||||||
|
|
||||||
|
let providers = opencode_config::get_typed_providers()?;
|
||||||
|
if providers.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut imported = 0;
|
||||||
|
let existing = state.db.get_all_providers("opencode")?;
|
||||||
|
|
||||||
|
for (id, config) in providers {
|
||||||
|
// Skip if already exists in database
|
||||||
|
if existing.contains_key(&id) {
|
||||||
|
log::debug!("OpenCode provider '{id}' already exists in database, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Value for settings_config
|
||||||
|
let settings_config = match serde_json::to_value(&config) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to serialize OpenCode provider '{id}': {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create provider
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
id.clone(),
|
||||||
|
config.name.clone().unwrap_or_else(|| id.clone()),
|
||||||
|
settings_config,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||||
|
log::warn!("Failed to import OpenCode provider '{id}': {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
imported += 1;
|
||||||
|
log::info!("Imported OpenCode provider '{id}' from live config");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(imported)
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,13 +20,17 @@ use crate::settings::CustomEndpoint;
|
|||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
// Re-export sub-module functions for external access
|
// Re-export sub-module functions for external access
|
||||||
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
|
pub use live::{
|
||||||
|
import_default_config, import_opencode_providers_from_live, read_live_settings,
|
||||||
|
sync_current_to_live,
|
||||||
|
};
|
||||||
|
|
||||||
// Internal re-exports (pub(crate))
|
// Internal re-exports (pub(crate))
|
||||||
|
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||||
pub(crate) use live::write_live_snapshot;
|
pub(crate) use live::write_live_snapshot;
|
||||||
|
|
||||||
// Internal re-exports
|
// Internal re-exports
|
||||||
use live::write_gemini_live;
|
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||||
use usage::validate_usage_script;
|
use usage::validate_usage_script;
|
||||||
|
|
||||||
/// Provider business logic service
|
/// Provider business logic service
|
||||||
@@ -137,7 +141,13 @@ impl ProviderService {
|
|||||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||||
|
///
|
||||||
|
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
|
||||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||||
|
// OpenCode uses additive mode - no "current" provider concept
|
||||||
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||||
.map(|opt| opt.unwrap_or_default())
|
.map(|opt| opt.unwrap_or_default())
|
||||||
}
|
}
|
||||||
@@ -152,7 +162,13 @@ impl ProviderService {
|
|||||||
// Save to database
|
// Save to database
|
||||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||||
|
|
||||||
// Check if sync is needed (if this is current provider, or no current provider)
|
// OpenCode uses additive mode - always write to live config
|
||||||
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
write_live_snapshot(&app_type, &provider)?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other apps: Check if sync is needed (if this is current provider, or no current provider)
|
||||||
let current = state.db.get_current_provider(app_type.as_str())?;
|
let current = state.db.get_current_provider(app_type.as_str())?;
|
||||||
if current.is_none() {
|
if current.is_none() {
|
||||||
// No current provider, set as current and sync
|
// No current provider, set as current and sync
|
||||||
@@ -176,14 +192,20 @@ impl ProviderService {
|
|||||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||||
Self::validate_provider_settings(&app_type, &provider)?;
|
Self::validate_provider_settings(&app_type, &provider)?;
|
||||||
|
|
||||||
// Check if this is current provider (use effective current, not just DB)
|
// Save to database
|
||||||
|
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||||
|
|
||||||
|
// OpenCode uses additive mode - always update in live config
|
||||||
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
write_live_snapshot(&app_type, &provider)?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other apps: Check if this is current provider (use effective current, not just DB)
|
||||||
let effective_current =
|
let effective_current =
|
||||||
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||||
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
|
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
|
||||||
|
|
||||||
// Save to database
|
|
||||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
|
||||||
|
|
||||||
if is_current {
|
if is_current {
|
||||||
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
||||||
// - 不写 Live 配置(否则会破坏接管)
|
// - 不写 Live 配置(否则会破坏接管)
|
||||||
@@ -216,8 +238,18 @@ impl ProviderService {
|
|||||||
/// Delete a provider
|
/// Delete a provider
|
||||||
///
|
///
|
||||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||||
|
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
// Check both local settings and database
|
// OpenCode uses additive mode - no current provider concept
|
||||||
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
// Remove from database
|
||||||
|
state.db.delete_provider(app_type.as_str(), id)?;
|
||||||
|
// Also remove from live config
|
||||||
|
remove_opencode_provider_from_live(id)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other apps: Check both local settings and database
|
||||||
let local_current = crate::settings::get_current_provider(&app_type);
|
let local_current = crate::settings::get_current_provider(&app_type);
|
||||||
let db_current = state.db.get_current_provider(app_type.as_str())?;
|
let db_current = state.db.get_current_provider(app_type.as_str())?;
|
||||||
|
|
||||||
@@ -230,6 +262,27 @@ impl ProviderService {
|
|||||||
state.db.delete_provider(app_type.as_str(), id)
|
state.db.delete_provider(app_type.as_str(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||||
|
///
|
||||||
|
/// Does NOT delete from database - provider remains in the list.
|
||||||
|
/// This is used when user wants to "remove" a provider from active config
|
||||||
|
/// but keep it available for future use.
|
||||||
|
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
match app_type {
|
||||||
|
AppType::OpenCode => {
|
||||||
|
remove_opencode_provider_from_live(id)?;
|
||||||
|
}
|
||||||
|
// Future: add other additive mode apps here
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::Message(format!(
|
||||||
|
"App {} does not support remove from live config",
|
||||||
|
app_type.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Switch to a provider
|
/// Switch to a provider
|
||||||
///
|
///
|
||||||
/// Switch flow:
|
/// Switch flow:
|
||||||
@@ -326,22 +379,29 @@ impl ProviderService {
|
|||||||
|
|
||||||
if let Some(current_id) = current_id {
|
if let Some(current_id) = current_id {
|
||||||
if current_id != id {
|
if current_id != id {
|
||||||
// Only backfill when switching to a different provider
|
// OpenCode uses additive mode - all providers coexist in the same file,
|
||||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
if !matches!(app_type, AppType::OpenCode) {
|
||||||
current_provider.settings_config = live_config;
|
// Only backfill when switching to a different provider
|
||||||
// Ignore backfill failure, don't affect switch flow
|
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||||
|
current_provider.settings_config = live_config;
|
||||||
|
// Ignore backfill failure, don't affect switch flow
|
||||||
|
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local settings (device-level, takes priority)
|
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
if !matches!(app_type, AppType::OpenCode) {
|
||||||
|
// Update local settings (device-level, takes priority)
|
||||||
|
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||||
|
|
||||||
// Update database is_current (as default for new devices)
|
// Update database is_current (as default for new devices)
|
||||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_snapshot(&app_type, provider)?;
|
||||||
@@ -380,6 +440,7 @@ impl ProviderService {
|
|||||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||||
|
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +453,7 @@ impl ProviderService {
|
|||||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||||
|
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +587,29 @@ impl ProviderService {
|
|||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract common config for OpenCode (JSON format)
|
||||||
|
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||||
|
// OpenCode uses a different config structure with npm, options, models
|
||||||
|
// For common config, we exclude provider-specific fields like apiKey
|
||||||
|
let mut config = settings.clone();
|
||||||
|
|
||||||
|
// Remove provider-specific fields
|
||||||
|
if let Some(obj) = config.as_object_mut() {
|
||||||
|
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
||||||
|
options.remove("apiKey");
|
||||||
|
options.remove("baseURL");
|
||||||
|
}
|
||||||
|
// Keep npm and models as they might be common
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||||
|
return Ok("{}".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string_pretty(&config)
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
/// Import default configuration from live files (re-export)
|
/// Import default configuration from live files (re-export)
|
||||||
///
|
///
|
||||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||||
@@ -615,6 +700,7 @@ impl ProviderService {
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
usage::test_usage_script(
|
usage::test_usage_script(
|
||||||
state,
|
state,
|
||||||
@@ -626,6 +712,7 @@ impl ProviderService {
|
|||||||
base_url,
|
base_url,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -689,6 +776,17 @@ impl ProviderService {
|
|||||||
use crate::gemini_config::validate_gemini_settings;
|
use crate::gemini_config::validate_gemini_settings;
|
||||||
validate_gemini_settings(&provider.settings_config)?
|
validate_gemini_settings(&provider.settings_config)?
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses a different config structure: { npm, options, models }
|
||||||
|
// Basic validation - must be an object
|
||||||
|
if !provider.settings_config.is_object() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"provider.opencode.settings.not_object",
|
||||||
|
"OpenCode 配置必须是 JSON 对象",
|
||||||
|
"OpenCode configuration must be a JSON object",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and clean UsageScript configuration (common for all app types)
|
// Validate and clean UsageScript configuration (common for all app types)
|
||||||
@@ -826,6 +924,40 @@ impl ProviderService {
|
|||||||
|
|
||||||
Ok((api_key, base_url))
|
Ok((api_key, base_url))
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses options.apiKey and options.baseURL
|
||||||
|
let options = provider
|
||||||
|
.settings_config
|
||||||
|
.get("options")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"provider.opencode.options.missing",
|
||||||
|
"配置格式错误: 缺少 options",
|
||||||
|
"Invalid configuration: missing options section",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let api_key = options
|
||||||
|
.get("apiKey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"provider.opencode.api_key.missing",
|
||||||
|
"缺少 API Key",
|
||||||
|
"API key is missing",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let base_url = options
|
||||||
|
.get("baseURL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok((api_key, base_url))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
timeout: u64,
|
timeout: u64,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
match usage_script::execute_usage_script(
|
match usage_script::execute_usage_script(
|
||||||
script_code,
|
script_code,
|
||||||
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -113,7 +115,7 @@ pub async fn query_usage(
|
|||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
|
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let provider = providers.get(provider_id).ok_or_else(|| {
|
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -164,6 +166,7 @@ pub async fn query_usage(
|
|||||||
base_url,
|
base_url,
|
||||||
usage_script.access_token.clone(),
|
usage_script.access_token.clone(),
|
||||||
usage_script.user_id.clone(),
|
usage_script.user_id.clone(),
|
||||||
|
usage_script.template_type.clone(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ pub async fn query_usage(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token.as_deref(),
|
access_token.as_deref(),
|
||||||
user_id.as_deref(),
|
user_id.as_deref(),
|
||||||
|
template_type.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
// Use provided credential parameters directly for testing
|
// Use provided credential parameters directly for testing
|
||||||
execute_and_format_usage_result(
|
execute_and_format_usage_result(
|
||||||
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,6 +368,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => self.read_claude_live()?,
|
AppType::Claude => self.read_claude_live()?,
|
||||||
AppType::Codex => self.read_codex_live()?,
|
AppType::Codex => self.read_codex_live()?,
|
||||||
AppType::Gemini => self.read_gemini_live()?,
|
AppType::Gemini => self.read_gemini_live()?,
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
return Err("OpenCode 不支持代理功能".to_string());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.sync_live_config_to_provider(app_type, &live_config)
|
self.sync_live_config_to_provider(app_type, &live_config)
|
||||||
@@ -581,6 +585,9 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features, skip silently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -759,6 +766,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => ("claude", self.read_claude_live()?),
|
AppType::Claude => ("claude", self.read_claude_live()?),
|
||||||
AppType::Codex => ("codex", self.read_codex_live()?),
|
AppType::Codex => ("codex", self.read_codex_live()?),
|
||||||
AppType::Gemini => ("gemini", self.read_gemini_live()?),
|
AppType::Gemini => ("gemini", self.read_gemini_live()?),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
return Err("OpenCode 不支持代理功能".to_string());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let json_str = serde_json::to_string(&config)
|
let json_str = serde_json::to_string(&config)
|
||||||
@@ -967,6 +978,10 @@ impl ProxyService {
|
|||||||
self.write_gemini_live(&live_config)?;
|
self.write_gemini_live(&live_config)?;
|
||||||
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
return Err("OpenCode 不支持代理功能".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1050,6 +1065,9 @@ impl ProxyService {
|
|||||||
let _ = self.write_gemini_live(&live_config);
|
let _ = self.write_gemini_live(&live_config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features, skip silently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1082,6 +1100,9 @@ impl ProxyService {
|
|||||||
log::info!("Gemini Live 配置已恢复");
|
log::info!("Gemini Live 配置已恢复");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features, skip silently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1161,6 +1182,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => self.write_claude_live(config),
|
AppType::Claude => self.write_claude_live(config),
|
||||||
AppType::Codex => self.write_codex_live(config),
|
AppType::Codex => self.write_codex_live(config),
|
||||||
AppType::Gemini => self.write_gemini_live(config),
|
AppType::Gemini => self.write_gemini_live(config),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
Err("OpenCode 不支持代理功能".to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,6 +1203,10 @@ impl ProxyService {
|
|||||||
Ok(config) => Self::is_gemini_live_taken_over(&config),
|
Ok(config) => Self::is_gemini_live_taken_over(&config),
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
},
|
},
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy takeover
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,6 +1246,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
|
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
|
||||||
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
||||||
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1497,7 +1530,10 @@ impl ProxyService {
|
|||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// 更新数据库中的 is_current 标记
|
// 代理模式切换供应商(热切换):
|
||||||
|
// - 更新 SSOT(数据库 is_current)
|
||||||
|
// - 同步本地 settings(设备级 current_provider_*)
|
||||||
|
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
|
||||||
let app_type_enum =
|
let app_type_enum =
|
||||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||||
|
|
||||||
@@ -1505,6 +1541,35 @@ impl ProxyService {
|
|||||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
|
// 同步本地 settings(设备级优先)
|
||||||
|
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||||
|
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
|
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
|
||||||
|
let has_backup = self
|
||||||
|
.db
|
||||||
|
.get_live_backup(app_type_enum.as_str())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||||
|
|
||||||
|
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||||
|
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
|
||||||
|
if has_backup || live_taken_over {
|
||||||
|
self.update_live_backup_from_provider(app_type, &provider)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
|
||||||
|
if let Some(server) = self.server.read().await.as_ref() {
|
||||||
|
server
|
||||||
|
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1589,7 +1654,8 @@ impl ProxyService {
|
|||||||
|
|
||||||
fn write_claude_live(&self, config: &Value) -> Result<(), String> {
|
fn write_claude_live(&self, config: &Value) -> Result<(), String> {
|
||||||
let path = get_claude_settings_path();
|
let path = get_claude_settings_path();
|
||||||
write_json_file(&path, config).map_err(|e| format!("写入 Claude 配置失败: {e}"))
|
let settings = crate::services::provider::sanitize_claude_settings_for_live(config);
|
||||||
|
write_json_file(&path, &settings).map_err(|e| format!("写入 Claude 配置失败: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_codex_live(&self) -> Result<Value, String> {
|
fn read_codex_live(&self) -> Result<Value, String> {
|
||||||
@@ -2025,4 +2091,66 @@ model = "gpt-5.1-codex"
|
|||||||
"should not add ANTHROPIC_AUTH_TOKEN when absent"
|
"should not add ANTHROPIC_AUTH_TOKEN when absent"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn switch_proxy_target_updates_live_backup_when_taken_over() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let service = ProxyService::new(db.clone());
|
||||||
|
|
||||||
|
let provider_a = Provider::with_id(
|
||||||
|
"a".to_string(),
|
||||||
|
"A".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "a-key"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let provider_b = Provider::with_id(
|
||||||
|
"b".to_string(),
|
||||||
|
"B".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "b-key"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
db.save_provider("claude", &provider_a)
|
||||||
|
.expect("save provider a");
|
||||||
|
db.save_provider("claude", &provider_b)
|
||||||
|
.expect("save provider b");
|
||||||
|
db.set_current_provider("claude", "a")
|
||||||
|
.expect("set current provider");
|
||||||
|
|
||||||
|
// 模拟“已接管”状态:存在 Live 备份(内容不重要,会被热切换更新)
|
||||||
|
db.save_live_backup("claude", "{\"env\":{}}")
|
||||||
|
.await
|
||||||
|
.expect("seed live backup");
|
||||||
|
|
||||||
|
service
|
||||||
|
.switch_proxy_target("claude", "b")
|
||||||
|
.await
|
||||||
|
.expect("switch proxy target");
|
||||||
|
|
||||||
|
// 断言:本地 settings 的 current provider 已同步
|
||||||
|
assert_eq!(
|
||||||
|
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
|
||||||
|
Some("b")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)
|
||||||
|
let backup = db
|
||||||
|
.get_live_backup("claude")
|
||||||
|
.await
|
||||||
|
.expect("get live backup")
|
||||||
|
.expect("backup exists");
|
||||||
|
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||||
|
assert_eq!(backup.original_config, expected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+384
-18
@@ -21,6 +21,19 @@ use crate::error::format_skill_error;
|
|||||||
|
|
||||||
// ========== 数据结构 ==========
|
// ========== 数据结构 ==========
|
||||||
|
|
||||||
|
/// Skill 同步方式
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SyncMethod {
|
||||||
|
/// 自动选择:优先 symlink,失败时回退到 copy
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// 符号链接(推荐,节省磁盘空间)
|
||||||
|
Symlink,
|
||||||
|
/// 文件复制(兼容模式)
|
||||||
|
Copy,
|
||||||
|
}
|
||||||
|
|
||||||
/// 可发现的技能(来自仓库)
|
/// 可发现的技能(来自仓库)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DiscoverableSkill {
|
pub struct DiscoverableSkill {
|
||||||
@@ -128,6 +141,12 @@ impl Default for SkillStore {
|
|||||||
branch: "master".to_string(),
|
branch: "master".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
SkillRepo {
|
||||||
|
owner: "JimLiu".to_string(),
|
||||||
|
name: "baoyu-skills".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,6 +202,11 @@ impl SkillService {
|
|||||||
return Ok(custom.join("skills"));
|
return Ok(custom.join("skills"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
if let Some(custom) = crate::settings::get_opencode_override_dir() {
|
||||||
|
return Ok(custom.join("skills"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认路径:回退到用户主目录下的标准位置
|
// 默认路径:回退到用户主目录下的标准位置
|
||||||
@@ -196,6 +220,7 @@ impl SkillService {
|
|||||||
AppType::Claude => home.join(".claude").join("skills"),
|
AppType::Claude => home.join(".claude").join("skills"),
|
||||||
AppType::Codex => home.join(".codex").join("skills"),
|
AppType::Codex => home.join(".codex").join("skills"),
|
||||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||||
|
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +252,50 @@ impl SkillService {
|
|||||||
.map(|s| s.to_string_lossy().to_string())
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| skill.directory.clone());
|
.unwrap_or_else(|| skill.directory.clone());
|
||||||
|
|
||||||
|
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
|
||||||
|
let existing_skills = db.get_all_installed_skills()?;
|
||||||
|
for existing in existing_skills.values() {
|
||||||
|
if existing.directory.eq_ignore_ascii_case(&install_name) {
|
||||||
|
// 检查是否来自同一仓库
|
||||||
|
let same_repo = existing.repo_owner.as_deref() == Some(&skill.repo_owner)
|
||||||
|
&& existing.repo_name.as_deref() == Some(&skill.repo_name);
|
||||||
|
if same_repo {
|
||||||
|
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
|
||||||
|
let mut updated = existing.clone();
|
||||||
|
updated.apps.set_enabled_for(current_app, true);
|
||||||
|
db.save_skill(&updated)?;
|
||||||
|
Self::sync_to_app_dir(&updated.directory, current_app)?;
|
||||||
|
log::info!(
|
||||||
|
"Skill {} 已存在,更新 {:?} 启用状态",
|
||||||
|
updated.name,
|
||||||
|
current_app
|
||||||
|
);
|
||||||
|
return Ok(updated);
|
||||||
|
} else {
|
||||||
|
// 不同仓库的同名 skill,报错
|
||||||
|
return Err(anyhow!(format_skill_error(
|
||||||
|
"SKILL_DIRECTORY_CONFLICT",
|
||||||
|
&[
|
||||||
|
("directory", &install_name),
|
||||||
|
(
|
||||||
|
"existing_repo",
|
||||||
|
&format!(
|
||||||
|
"{}/{}",
|
||||||
|
existing.repo_owner.as_deref().unwrap_or("unknown"),
|
||||||
|
existing.repo_name.as_deref().unwrap_or("unknown")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"new_repo",
|
||||||
|
&format!("{}/{}", skill.repo_owner, skill.repo_name)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Some("uninstallFirst"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let dest = ssot_dir.join(&install_name);
|
let dest = ssot_dir.join(&install_name);
|
||||||
|
|
||||||
// 如果已存在则跳过下载
|
// 如果已存在则跳过下载
|
||||||
@@ -293,7 +362,7 @@ impl SkillService {
|
|||||||
db.save_skill(&installed_skill)?;
|
db.save_skill(&installed_skill)?;
|
||||||
|
|
||||||
// 同步到当前应用目录
|
// 同步到当前应用目录
|
||||||
Self::copy_to_app(&install_name, current_app)?;
|
Self::sync_to_app_dir(&install_name, current_app)?;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skill {} 安装成功,已启用 {:?}",
|
"Skill {} 安装成功,已启用 {:?}",
|
||||||
@@ -317,7 +386,12 @@ impl SkillService {
|
|||||||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||||||
|
|
||||||
// 从所有应用目录删除
|
// 从所有应用目录删除
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
let _ = Self::remove_from_app(&skill.directory, &app);
|
let _ = Self::remove_from_app(&skill.directory, &app);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +425,7 @@ impl SkillService {
|
|||||||
|
|
||||||
// 同步文件
|
// 同步文件
|
||||||
if enabled {
|
if enabled {
|
||||||
Self::copy_to_app(&skill.directory, app)?;
|
Self::sync_to_app_dir(&skill.directory, app)?;
|
||||||
} else {
|
} else {
|
||||||
Self::remove_from_app(&skill.directory, app)?;
|
Self::remove_from_app(&skill.directory, app)?;
|
||||||
}
|
}
|
||||||
@@ -376,7 +450,12 @@ impl SkillService {
|
|||||||
|
|
||||||
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
||||||
|
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
let app_dir = match Self::get_app_skills_dir(&app) {
|
let app_dir = match Self::get_app_skills_dir(&app) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
@@ -425,6 +504,7 @@ impl SkillService {
|
|||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini",
|
AppType::Gemini => "gemini",
|
||||||
|
AppType::OpenCode => "opencode",
|
||||||
};
|
};
|
||||||
|
|
||||||
unmanaged
|
unmanaged
|
||||||
@@ -457,7 +537,12 @@ impl SkillService {
|
|||||||
let mut source_path: Option<PathBuf> = None;
|
let mut source_path: Option<PathBuf> = None;
|
||||||
let mut found_in: Vec<String> = Vec::new();
|
let mut found_in: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
|
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
|
||||||
let skill_path = app_dir.join(&dir_name);
|
let skill_path = app_dir.join(&dir_name);
|
||||||
if skill_path.exists() {
|
if skill_path.exists() {
|
||||||
@@ -468,6 +553,7 @@ impl SkillService {
|
|||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini",
|
AppType::Gemini => "gemini",
|
||||||
|
AppType::OpenCode => "opencode",
|
||||||
};
|
};
|
||||||
found_in.push(app_str.to_string());
|
found_in.push(app_str.to_string());
|
||||||
}
|
}
|
||||||
@@ -506,6 +592,7 @@ impl SkillService {
|
|||||||
"claude" => apps.claude = true,
|
"claude" => apps.claude = true,
|
||||||
"codex" => apps.codex = true,
|
"codex" => apps.codex = true,
|
||||||
"gemini" => apps.gemini = true,
|
"gemini" => apps.gemini = true,
|
||||||
|
"opencode" => apps.opencode = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,8 +623,41 @@ impl SkillService {
|
|||||||
|
|
||||||
// ========== 文件同步方法 ==========
|
// ========== 文件同步方法 ==========
|
||||||
|
|
||||||
/// 复制 Skill 到应用目录
|
/// 创建符号链接(跨平台)
|
||||||
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
|
///
|
||||||
|
/// - Unix: 使用 std::os::unix::fs::symlink
|
||||||
|
/// - Windows: 使用 std::os::windows::fs::symlink_dir
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
std::os::unix::fs::symlink(src, dest)
|
||||||
|
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
std::os::windows::fs::symlink_dir(src, dest)
|
||||||
|
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查路径是否为符号链接
|
||||||
|
fn is_symlink(path: &Path) -> bool {
|
||||||
|
path.symlink_metadata()
|
||||||
|
.map(|m| m.file_type().is_symlink())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前同步方式配置
|
||||||
|
fn get_sync_method() -> SyncMethod {
|
||||||
|
crate::settings::get_skill_sync_method()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步 Skill 到应用目录(使用 symlink 或 copy)
|
||||||
|
///
|
||||||
|
/// 根据配置和平台选择最佳同步方式:
|
||||||
|
/// - Auto: 优先尝试 symlink,失败时回退到 copy
|
||||||
|
/// - Symlink: 仅使用 symlink
|
||||||
|
/// - Copy: 仅使用文件复制
|
||||||
|
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
|
||||||
let ssot_dir = Self::get_ssot_dir()?;
|
let ssot_dir = Self::get_ssot_dir()?;
|
||||||
let source = ssot_dir.join(directory);
|
let source = ssot_dir.join(directory);
|
||||||
|
|
||||||
@@ -550,25 +670,77 @@ impl SkillService {
|
|||||||
|
|
||||||
let dest = app_dir.join(directory);
|
let dest = app_dir.join(directory);
|
||||||
|
|
||||||
// 如果已存在则先删除
|
// 如果已存在则先删除(无论是 symlink 还是真实目录)
|
||||||
if dest.exists() {
|
if dest.exists() || Self::is_symlink(&dest) {
|
||||||
fs::remove_dir_all(&dest)?;
|
Self::remove_path(&dest)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::copy_dir_recursive(&source, &dest)?;
|
let sync_method = Self::get_sync_method();
|
||||||
|
|
||||||
log::debug!("Skill {directory} 已复制到 {app:?}");
|
match sync_method {
|
||||||
|
SyncMethod::Auto => {
|
||||||
|
// 优先尝试 symlink
|
||||||
|
match Self::create_symlink(&source, &dest) {
|
||||||
|
Ok(()) => {
|
||||||
|
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Symlink 创建失败,将回退到文件复制: {} -> {}. 错误: {err:#}",
|
||||||
|
source.display(),
|
||||||
|
dest.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback 到 copy
|
||||||
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
|
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||||||
|
}
|
||||||
|
SyncMethod::Symlink => {
|
||||||
|
Self::create_symlink(&source, &dest)?;
|
||||||
|
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||||||
|
}
|
||||||
|
SyncMethod::Copy => {
|
||||||
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
|
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从应用目录删除 Skill
|
/// 复制 Skill 到应用目录(保留用于向后兼容)
|
||||||
|
#[deprecated(note = "请使用 sync_to_app_dir() 代替")]
|
||||||
|
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
|
||||||
|
Self::sync_to_app_dir(directory, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除路径(支持 symlink 和真实目录)
|
||||||
|
fn remove_path(path: &Path) -> Result<()> {
|
||||||
|
if Self::is_symlink(path) {
|
||||||
|
// 符号链接:仅删除链接本身,不影响源文件
|
||||||
|
#[cfg(unix)]
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
#[cfg(windows)]
|
||||||
|
fs::remove_dir(path)?; // Windows 的目录 symlink 需要用 remove_dir
|
||||||
|
} else if path.is_dir() {
|
||||||
|
// 真实目录:递归删除
|
||||||
|
fs::remove_dir_all(path)?;
|
||||||
|
} else if path.exists() {
|
||||||
|
// 普通文件
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
|
||||||
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
||||||
let app_dir = Self::get_app_skills_dir(app)?;
|
let app_dir = Self::get_app_skills_dir(app)?;
|
||||||
let skill_path = app_dir.join(directory);
|
let skill_path = app_dir.join(directory);
|
||||||
|
|
||||||
if skill_path.exists() {
|
if skill_path.exists() || Self::is_symlink(&skill_path) {
|
||||||
fs::remove_dir_all(&skill_path)?;
|
Self::remove_path(&skill_path)?;
|
||||||
log::debug!("Skill {directory} 已从 {app:?} 删除");
|
log::debug!("Skill {directory} 已从 {app:?} 删除");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,7 +753,7 @@ impl SkillService {
|
|||||||
|
|
||||||
for skill in skills.values() {
|
for skill in skills.values() {
|
||||||
if skill.apps.is_enabled_for(app) {
|
if skill.apps.is_enabled_for(app) {
|
||||||
Self::copy_to_app(&skill.directory, app)?;
|
Self::sync_to_app_dir(&skill.directory, app)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,10 +977,12 @@ impl SkillService {
|
|||||||
Ok(meta)
|
Ok(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 去重技能列表
|
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
||||||
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
||||||
let mut seen = HashMap::new();
|
let mut seen = HashMap::new();
|
||||||
skills.retain(|skill| {
|
skills.retain(|skill| {
|
||||||
|
// 使用完整 key(owner/repo:directory)作为唯一标识
|
||||||
|
// 这样不同仓库的同名 skill 会分开显示
|
||||||
let unique_key = skill.key.to_lowercase();
|
let unique_key = skill.key.to_lowercase();
|
||||||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
|
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
|
||||||
e.insert(true);
|
e.insert(true);
|
||||||
@@ -936,6 +1110,193 @@ impl SkillService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 从 ZIP 文件安装 ==========
|
||||||
|
|
||||||
|
/// 从本地 ZIP 文件安装 Skills
|
||||||
|
///
|
||||||
|
/// 流程:
|
||||||
|
/// 1. 解压 ZIP 到临时目录
|
||||||
|
/// 2. 扫描目录查找包含 SKILL.md 的技能
|
||||||
|
/// 3. 复制到 SSOT 并保存到数据库
|
||||||
|
/// 4. 同步到当前应用目录
|
||||||
|
pub fn install_from_zip(
|
||||||
|
db: &Arc<Database>,
|
||||||
|
zip_path: &Path,
|
||||||
|
current_app: &AppType,
|
||||||
|
) -> Result<Vec<InstalledSkill>> {
|
||||||
|
// 解压到临时目录
|
||||||
|
let temp_dir = Self::extract_local_zip(zip_path)?;
|
||||||
|
|
||||||
|
// 扫描所有包含 SKILL.md 的目录
|
||||||
|
let skill_dirs = Self::scan_skills_in_dir(&temp_dir)?;
|
||||||
|
|
||||||
|
if skill_dirs.is_empty() {
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(anyhow!(format_skill_error(
|
||||||
|
"NO_SKILLS_IN_ZIP",
|
||||||
|
&[],
|
||||||
|
Some("checkZipContent"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ssot_dir = Self::get_ssot_dir()?;
|
||||||
|
let mut installed = Vec::new();
|
||||||
|
let existing_skills = db.get_all_installed_skills()?;
|
||||||
|
|
||||||
|
for skill_dir in skill_dirs {
|
||||||
|
// 获取目录名称作为安装名
|
||||||
|
let install_name = skill_dir
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// 检查是否已有同名 directory 的 skill
|
||||||
|
let conflict = existing_skills
|
||||||
|
.values()
|
||||||
|
.find(|s| s.directory.eq_ignore_ascii_case(&install_name));
|
||||||
|
|
||||||
|
if let Some(existing) = conflict {
|
||||||
|
log::warn!(
|
||||||
|
"Skill directory '{}' already exists (from {}), skipping",
|
||||||
|
install_name,
|
||||||
|
existing.id
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析元数据
|
||||||
|
let skill_md = skill_dir.join("SKILL.md");
|
||||||
|
let (name, description) = if skill_md.exists() {
|
||||||
|
match Self::parse_skill_metadata_static(&skill_md) {
|
||||||
|
Ok(meta) => (
|
||||||
|
meta.name.unwrap_or_else(|| install_name.clone()),
|
||||||
|
meta.description,
|
||||||
|
),
|
||||||
|
Err(_) => (install_name.clone(), None),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(install_name.clone(), None)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复制到 SSOT
|
||||||
|
let dest = ssot_dir.join(&install_name);
|
||||||
|
if dest.exists() {
|
||||||
|
let _ = fs::remove_dir_all(&dest);
|
||||||
|
}
|
||||||
|
Self::copy_dir_recursive(&skill_dir, &dest)?;
|
||||||
|
|
||||||
|
// 创建 InstalledSkill 记录
|
||||||
|
let skill = InstalledSkill {
|
||||||
|
id: format!("local:{install_name}"),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
directory: install_name.clone(),
|
||||||
|
repo_owner: None,
|
||||||
|
repo_name: None,
|
||||||
|
repo_branch: None,
|
||||||
|
readme_url: None,
|
||||||
|
apps: SkillApps::only(current_app),
|
||||||
|
installed_at: chrono::Utc::now().timestamp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
db.save_skill(&skill)?;
|
||||||
|
|
||||||
|
// 同步到当前应用目录
|
||||||
|
Self::sync_to_app_dir(&install_name, current_app)?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Skill {} installed from ZIP, enabled for {:?}",
|
||||||
|
skill.name,
|
||||||
|
current_app
|
||||||
|
);
|
||||||
|
installed.push(skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理临时目录
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
|
Ok(installed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解压本地 ZIP 文件到临时目录
|
||||||
|
fn extract_local_zip(zip_path: &Path) -> Result<PathBuf> {
|
||||||
|
let file = fs::File::open(zip_path)
|
||||||
|
.with_context(|| format!("Failed to open ZIP file: {}", zip_path.display()))?;
|
||||||
|
|
||||||
|
let mut archive = zip::ZipArchive::new(file)
|
||||||
|
.with_context(|| format!("Failed to read ZIP file: {}", zip_path.display()))?;
|
||||||
|
|
||||||
|
if archive.is_empty() {
|
||||||
|
return Err(anyhow!(format_skill_error(
|
||||||
|
"EMPTY_ARCHIVE",
|
||||||
|
&[],
|
||||||
|
Some("checkZipContent"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir()?;
|
||||||
|
let temp_path = temp_dir.path().to_path_buf();
|
||||||
|
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
|
||||||
|
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut file = archive.by_index(i)?;
|
||||||
|
let file_path = match file.enclosed_name() {
|
||||||
|
Some(path) => path.to_owned(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let outpath = temp_path.join(&file_path);
|
||||||
|
|
||||||
|
if file.is_dir() {
|
||||||
|
fs::create_dir_all(&outpath)?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = outpath.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut outfile = fs::File::create(&outpath)?;
|
||||||
|
std::io::copy(&mut file, &mut outfile)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(temp_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归扫描目录查找包含 SKILL.md 的技能目录
|
||||||
|
fn scan_skills_in_dir(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||||
|
let mut skill_dirs = Vec::new();
|
||||||
|
Self::scan_skills_recursive(dir, &mut skill_dirs)?;
|
||||||
|
Ok(skill_dirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归扫描辅助函数
|
||||||
|
fn scan_skills_recursive(current: &Path, results: &mut Vec<PathBuf>) -> Result<()> {
|
||||||
|
// 检查当前目录是否包含 SKILL.md
|
||||||
|
let skill_md = current.join("SKILL.md");
|
||||||
|
if skill_md.exists() {
|
||||||
|
results.push(current.to_path_buf());
|
||||||
|
// 找到后不再递归子目录(一个 skill 目录)
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归子目录
|
||||||
|
if let Ok(entries) = fs::read_dir(current) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
// 跳过隐藏目录
|
||||||
|
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if dir_name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Self::scan_skills_recursive(&path, results)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 仓库管理(保留原有逻辑)==========
|
// ========== 仓库管理(保留原有逻辑)==========
|
||||||
|
|
||||||
/// 列出仓库
|
/// 列出仓库
|
||||||
@@ -976,7 +1337,12 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
|||||||
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
||||||
|
|
||||||
// 扫描各应用目录
|
// 扫描各应用目录
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use std::time::Instant;
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::providers::{get_adapter, AuthInfo};
|
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
|
||||||
|
|
||||||
/// 健康状态枚举
|
/// 健康状态枚举
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -78,15 +78,19 @@ pub struct StreamCheckService;
|
|||||||
|
|
||||||
impl StreamCheckService {
|
impl StreamCheckService {
|
||||||
/// 执行流式健康检查(带重试)
|
/// 执行流式健康检查(带重试)
|
||||||
|
///
|
||||||
|
/// 如果 Provider 配置了单独的测试配置(meta.testConfig),则使用该配置覆盖全局配置
|
||||||
pub async fn check_with_retry(
|
pub async fn check_with_retry(
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
config: &StreamCheckConfig,
|
config: &StreamCheckConfig,
|
||||||
) -> Result<StreamCheckResult, AppError> {
|
) -> Result<StreamCheckResult, AppError> {
|
||||||
|
// 合并供应商单独配置和全局配置
|
||||||
|
let effective_config = Self::merge_provider_config(provider, config);
|
||||||
let mut last_result = None;
|
let mut last_result = None;
|
||||||
|
|
||||||
for attempt in 0..=config.max_retries {
|
for attempt in 0..=effective_config.max_retries {
|
||||||
let result = Self::check_once(app_type, provider, config).await;
|
let result = Self::check_once(app_type, provider, &effective_config).await;
|
||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(r) if r.success => {
|
Ok(r) if r.success => {
|
||||||
@@ -97,7 +101,7 @@ impl StreamCheckService {
|
|||||||
}
|
}
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
// 失败但非异常,判断是否重试
|
// 失败但非异常,判断是否重试
|
||||||
if Self::should_retry(&r.message) && attempt < config.max_retries {
|
if Self::should_retry(&r.message) && attempt < effective_config.max_retries {
|
||||||
last_result = Some(r.clone());
|
last_result = Some(r.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -107,7 +111,8 @@ impl StreamCheckService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if Self::should_retry(&e.to_string()) && attempt < config.max_retries {
|
if Self::should_retry(&e.to_string()) && attempt < effective_config.max_retries
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return Err(AppError::Message(e.to_string()));
|
return Err(AppError::Message(e.to_string()));
|
||||||
@@ -123,10 +128,51 @@ impl StreamCheckService {
|
|||||||
http_status: None,
|
http_status: None,
|
||||||
model_used: String::new(),
|
model_used: String::new(),
|
||||||
tested_at: chrono::Utc::now().timestamp(),
|
tested_at: chrono::Utc::now().timestamp(),
|
||||||
retry_count: config.max_retries,
|
retry_count: effective_config.max_retries,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 合并供应商单独配置和全局配置
|
||||||
|
///
|
||||||
|
/// 如果供应商配置了 meta.testConfig 且 enabled 为 true,则使用供应商配置覆盖全局配置
|
||||||
|
fn merge_provider_config(
|
||||||
|
provider: &Provider,
|
||||||
|
global_config: &StreamCheckConfig,
|
||||||
|
) -> StreamCheckConfig {
|
||||||
|
let test_config = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.test_config.as_ref())
|
||||||
|
.filter(|tc| tc.enabled);
|
||||||
|
|
||||||
|
match test_config {
|
||||||
|
Some(tc) => StreamCheckConfig {
|
||||||
|
timeout_secs: tc.timeout_secs.unwrap_or(global_config.timeout_secs),
|
||||||
|
max_retries: tc.max_retries.unwrap_or(global_config.max_retries),
|
||||||
|
degraded_threshold_ms: tc
|
||||||
|
.degraded_threshold_ms
|
||||||
|
.unwrap_or(global_config.degraded_threshold_ms),
|
||||||
|
claude_model: tc
|
||||||
|
.test_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| global_config.claude_model.clone()),
|
||||||
|
codex_model: tc
|
||||||
|
.test_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| global_config.codex_model.clone()),
|
||||||
|
gemini_model: tc
|
||||||
|
.test_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| global_config.gemini_model.clone()),
|
||||||
|
test_prompt: tc
|
||||||
|
.test_prompt
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| global_config.test_prompt.clone()),
|
||||||
|
},
|
||||||
|
None => global_config.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 单次流式检查
|
/// 单次流式检查
|
||||||
async fn check_once(
|
async fn check_once(
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
@@ -144,8 +190,9 @@ impl StreamCheckService {
|
|||||||
.extract_auth(provider)
|
.extract_auth(provider)
|
||||||
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
||||||
|
|
||||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||||
let client = crate::proxy::http_client::get();
|
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||||
|
let client = crate::proxy::http_client::get_for_provider(proxy_config);
|
||||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||||
|
|
||||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||||
@@ -185,6 +232,14 @@ impl StreamCheckService {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support stream check yet
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"opencode_no_stream_check",
|
||||||
|
"OpenCode 暂不支持健康检查",
|
||||||
|
"OpenCode does not support health check yet",
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let response_time = start.elapsed().as_millis() as u64;
|
let response_time = start.elapsed().as_millis() as u64;
|
||||||
@@ -248,12 +303,18 @@ impl StreamCheckService {
|
|||||||
let os_name = Self::get_os_name();
|
let os_name = Self::get_os_name();
|
||||||
let arch_name = Self::get_arch_name();
|
let arch_name = Self::get_arch_name();
|
||||||
|
|
||||||
// 严格按照 Claude CLI 请求格式设置 headers
|
// 根据 auth.strategy 构建认证 headers
|
||||||
let response = client
|
let mut request_builder = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
// 认证 headers(双重认证)
|
.header("authorization", format!("Bearer {}", auth.api_key));
|
||||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
|
||||||
.header("x-api-key", &auth.api_key)
|
// 只有 Anthropic 官方策略才添加 x-api-key
|
||||||
|
if auth.strategy == AuthStrategy::Anthropic {
|
||||||
|
request_builder = request_builder.header("x-api-key", &auth.api_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 严格按照 Claude CLI 请求格式设置其他 headers
|
||||||
|
let response = request_builder
|
||||||
// Anthropic 必需 headers
|
// Anthropic 必需 headers
|
||||||
.header("anthropic-version", "2023-06-01")
|
.header("anthropic-version", "2023-06-01")
|
||||||
.header(
|
.header(
|
||||||
@@ -318,11 +379,15 @@ impl StreamCheckService {
|
|||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
|
// Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀),
|
||||||
let url = if base.ends_with("/v1") {
|
// Responses 端点为 `/responses`。
|
||||||
format!("{base}/responses")
|
//
|
||||||
|
// 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。
|
||||||
|
// 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。
|
||||||
|
let urls = if base.ends_with("/v1") {
|
||||||
|
vec![format!("{base}/responses")]
|
||||||
} else {
|
} else {
|
||||||
format!("{base}/v1/responses")
|
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
||||||
};
|
};
|
||||||
|
|
||||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||||
@@ -344,43 +409,55 @@ impl StreamCheckService {
|
|||||||
body["reasoning"] = json!({ "effort": effort });
|
body["reasoning"] = json!({ "effort": effort });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 严格按照 Codex CLI 请求格式设置 headers
|
for (i, url) in urls.iter().enumerate() {
|
||||||
let response = client
|
// 严格按照 Codex CLI 请求格式设置 headers
|
||||||
.post(&url)
|
let response = client
|
||||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
.post(url)
|
||||||
.header("content-type", "application/json")
|
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||||
.header("accept", "text/event-stream")
|
.header("content-type", "application/json")
|
||||||
.header("accept-encoding", "identity")
|
.header("accept", "text/event-stream")
|
||||||
.header(
|
.header("accept-encoding", "identity")
|
||||||
"user-agent",
|
.header(
|
||||||
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
|
"user-agent",
|
||||||
)
|
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
|
||||||
.header("originator", "codex_cli_rs")
|
)
|
||||||
.timeout(timeout)
|
.header("originator", "codex_cli_rs")
|
||||||
.json(&body)
|
.timeout(timeout)
|
||||||
.send()
|
.json(&body)
|
||||||
.await
|
.send()
|
||||||
.map_err(Self::map_request_error)?;
|
.await
|
||||||
|
.map_err(Self::map_request_error)?;
|
||||||
|
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
// 回退策略:仅当首选 URL 返回 404 时尝试下一个
|
||||||
}
|
if i == 0 && status == 404 && urls.len() > 1 {
|
||||||
|
continue;
|
||||||
let mut stream = response.bytes_stream();
|
}
|
||||||
if let Some(chunk) = stream.next().await {
|
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||||
match chunk {
|
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
|
||||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Err(AppError::Message("No response data received".to_string()))
|
let mut stream = response.bytes_stream();
|
||||||
|
if let Some(chunk) = stream.next().await {
|
||||||
|
match chunk {
|
||||||
|
Ok(_) => return Ok((status, actual_model)),
|
||||||
|
Err(e) => return Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(AppError::Message("No response data received".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Err(AppError::Message(
|
||||||
|
"No valid Codex responses endpoint found".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gemini 流式检查
|
/// Gemini 流式检查
|
||||||
|
///
|
||||||
|
/// 使用 Gemini 原生 API 格式 (streamGenerateContent)
|
||||||
async fn check_gemini_stream(
|
async fn check_gemini_stream(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
@@ -390,20 +467,28 @@ impl StreamCheckService {
|
|||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
let url = format!("{base}/v1/chat/completions");
|
// Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse
|
||||||
|
// 智能处理 /v1beta 路径:如果 base_url 不包含版本路径,则添加 /v1beta
|
||||||
|
// alt=sse 参数使 API 返回 SSE 格式(text/event-stream)而非 JSON 数组
|
||||||
|
let url = if base.contains("/v1beta") || base.contains("/v1/") {
|
||||||
|
format!("{base}/models/{model}:streamGenerateContent?alt=sse")
|
||||||
|
} else {
|
||||||
|
format!("{base}/v1beta/models/{model}:streamGenerateContent?alt=sse")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gemini 原生请求体格式
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"contents": [{
|
||||||
"messages": [{ "role": "user", "content": test_prompt }],
|
"role": "user",
|
||||||
"max_tokens": 1,
|
"parts": [{ "text": test_prompt }]
|
||||||
"temperature": 0,
|
}]
|
||||||
"stream": true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
.header("x-goog-api-key", &auth.api_key)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
.timeout(timeout)
|
.timeout(timeout)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
@@ -477,9 +562,24 @@ impl StreamCheckService {
|
|||||||
}
|
}
|
||||||
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
||||||
.unwrap_or_else(|| config.gemini_model.clone()),
|
.unwrap_or_else(|| config.gemini_model.clone()),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses models map in settings_config
|
||||||
|
// Try to extract first model from the models object
|
||||||
|
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_opencode_model(provider: &Provider) -> Option<String> {
|
||||||
|
let models = provider
|
||||||
|
.settings_config
|
||||||
|
.get("models")
|
||||||
|
.and_then(|m| m.as_object())?;
|
||||||
|
|
||||||
|
// Return the first model ID from the models map
|
||||||
|
models.keys().next().map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||||
provider
|
provider
|
||||||
.settings_config
|
.settings_config
|
||||||
@@ -609,4 +709,22 @@ mod tests {
|
|||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
assert_eq!(arch_name, "x86_64");
|
assert_eq!(arch_name, "x86_64");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_strategy_imports() {
|
||||||
|
// 验证 AuthStrategy 枚举可以正常使用
|
||||||
|
let anthropic = AuthStrategy::Anthropic;
|
||||||
|
let claude_auth = AuthStrategy::ClaudeAuth;
|
||||||
|
let bearer = AuthStrategy::Bearer;
|
||||||
|
|
||||||
|
// 验证不同的策略是不相等的
|
||||||
|
assert_ne!(anthropic, claude_auth);
|
||||||
|
assert_ne!(anthropic, bearer);
|
||||||
|
assert_ne!(claude_auth, bearer);
|
||||||
|
|
||||||
|
// 验证相同策略是相等的
|
||||||
|
assert_eq!(anthropic, AuthStrategy::Anthropic);
|
||||||
|
assert_eq!(claude_auth, AuthStrategy::ClaudeAuth);
|
||||||
|
assert_eq!(bearer, AuthStrategy::Bearer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
//! Claude 应用适配器
|
|
||||||
//!
|
|
||||||
//! 完整支持所有组件类型:
|
|
||||||
//! - Agent → `~/.claude/agents/{name}.md`
|
|
||||||
//! - Command → `~/.claude/commands/{name}.md`
|
|
||||||
//! - MCP → 合并到 `~/.claude.json` 的 mcpServers 字段
|
|
||||||
//! - Setting → 合并到 `~/.claude/settings.json` 的 permissions 字段
|
|
||||||
//! - Hook → 合并到 `~/.claude/settings.json` 的 hooks 字段
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::AppAdapter;
|
|
||||||
use crate::config::{atomic_write, get_claude_config_dir, get_claude_mcp_path};
|
|
||||||
|
|
||||||
/// Claude 应用适配器
|
|
||||||
pub struct ClaudeAdapter {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClaudeAdapter {
|
|
||||||
/// 创建新的 Claude 适配器实例
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
config_dir: get_claude_config_dir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
|
||||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(serde_json::json!({}));
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
|
||||||
let value: Value = serde_json::from_str(&content)
|
|
||||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 JSON 配置文件(原子写入)
|
|
||||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
|
||||||
|
|
||||||
atomic_write(path, json.as_bytes())
|
|
||||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 合并两个 JSON 对象(深度合并)
|
|
||||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
|
||||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
|
||||||
for (key, value) in overlay_obj {
|
|
||||||
if let Some(base_value) = base_obj.get_mut(key) {
|
|
||||||
// 如果两边都是对象,递归合并
|
|
||||||
if base_value.is_object() && value.is_object() {
|
|
||||||
Self::merge_json(base_value, value);
|
|
||||||
} else {
|
|
||||||
// 否则直接覆盖
|
|
||||||
*base_value = value.clone();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 键不存在,直接插入
|
|
||||||
base_obj.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Markdown 文件(通用)
|
|
||||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
let dir = self.config_dir.join(subdir);
|
|
||||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
|
||||||
|
|
||||||
let filename = if name.ends_with(".md") {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{name}.md")
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = dir.join(&filename);
|
|
||||||
|
|
||||||
atomic_write(&file_path, content.as_bytes())
|
|
||||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude {}: {}", subdir, file_path.display());
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Claude settings.json 路径
|
|
||||||
fn get_settings_path(&self) -> PathBuf {
|
|
||||||
crate::config::get_claude_settings_path()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppAdapter for ClaudeAdapter {
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "agents", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "commands", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
|
||||||
let mcp_path = get_claude_mcp_path();
|
|
||||||
|
|
||||||
// 读取现有 MCP 配置
|
|
||||||
let mut current = Self::read_json_file(&mcp_path)?;
|
|
||||||
|
|
||||||
// 确保 mcpServers 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("mcpServers").is_none() {
|
|
||||||
current["mcpServers"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并新的 MCP 服务器配置
|
|
||||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
|
||||||
Self::merge_json(mcp_servers, mcp_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&mcp_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude MCP 配置到: {}", mcp_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_setting(&self, setting_config: &Value) -> Result<()> {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
|
|
||||||
// 读取现有配置
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 确保 permissions 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("permissions").is_none() {
|
|
||||||
current["permissions"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并新的 permissions 配置
|
|
||||||
if let Some(permissions) = current.get_mut("permissions") {
|
|
||||||
Self::merge_json(permissions, setting_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude Setting 配置到: {}", settings_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_hook(&self, hook_config: &Value) -> Result<()> {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
|
|
||||||
// 读取现有配置
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 确保 hooks 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("hooks").is_none() {
|
|
||||||
current["hooks"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并新的 hooks 配置
|
|
||||||
if let Some(hooks) = current.get_mut("hooks") {
|
|
||||||
Self::merge_json(hooks, hook_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude Hook 配置到: {}", settings_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
|
||||||
match component_type.to_lowercase().as_str() {
|
|
||||||
"agent" => {
|
|
||||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Claude Agent: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"command" => {
|
|
||||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Claude Command: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let mcp_path = get_claude_mcp_path();
|
|
||||||
let mut current = Self::read_json_file(&mcp_path)?;
|
|
||||||
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcpServers")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
mcp_servers.remove(name);
|
|
||||||
Self::write_json_file(&mcp_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Claude MCP 服务器: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"setting" => {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
if let Some(permissions) = current
|
|
||||||
.get_mut("permissions")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
permissions.remove(name);
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Claude Setting: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"hook" => {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
if let Some(hooks) = current.get_mut("hooks").and_then(|v| v.as_object_mut()) {
|
|
||||||
hooks.remove(name);
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Claude Hook: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("不支持的组件类型: {component_type}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_dir(&self) -> PathBuf {
|
|
||||||
self.config_dir.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
component_type.to_lowercase().as_str(),
|
|
||||||
"agent" | "command" | "mcp" | "setting" | "hook"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ClaudeAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
//! Codex 应用适配器
|
|
||||||
//!
|
|
||||||
//! 部分支持:
|
|
||||||
//! - Agent → `~/.codex/agents/{name}.md`
|
|
||||||
//! - Command → `~/.codex/commands/{name}.md`
|
|
||||||
//! - MCP → 合并到 `~/.codex/config.toml` 的 [mcp_servers] 表
|
|
||||||
//! - Setting/Hook → 不支持(Codex 不支持这些功能)
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::AppAdapter;
|
|
||||||
use crate::codex_config::get_codex_config_dir;
|
|
||||||
use crate::config::{atomic_write, write_text_file};
|
|
||||||
|
|
||||||
/// Codex 应用适配器
|
|
||||||
pub struct CodexAdapter {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CodexAdapter {
|
|
||||||
/// 创建新的 Codex 适配器实例
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
config_dir: get_codex_config_dir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Markdown 文件(通用)
|
|
||||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
let dir = self.config_dir.join(subdir);
|
|
||||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
|
||||||
|
|
||||||
let filename = if name.ends_with(".md") {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{name}.md")
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = dir.join(&filename);
|
|
||||||
|
|
||||||
atomic_write(&file_path, content.as_bytes())
|
|
||||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
|
||||||
|
|
||||||
log::info!("已安装 Codex {}: {}", subdir, file_path.display());
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Codex config.toml 路径
|
|
||||||
fn get_config_toml_path(&self) -> PathBuf {
|
|
||||||
crate::codex_config::get_codex_config_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 TOML 配置文件
|
|
||||||
fn read_toml_file(path: &PathBuf) -> Result<toml::Table> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(toml::Table::new());
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
|
||||||
let table: toml::Table = toml::from_str(&content)
|
|
||||||
.with_context(|| format!("解析 TOML 失败: {}", path.display()))?;
|
|
||||||
Ok(table)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 TOML 配置文件(原子写入)
|
|
||||||
fn write_toml_file(path: &Path, table: &toml::Table) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let toml_string = toml::to_string_pretty(table).context("序列化 TOML 失败")?;
|
|
||||||
|
|
||||||
write_text_file(path, &toml_string)
|
|
||||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 JSON MCP 配置转换为 TOML 格式
|
|
||||||
fn json_mcp_to_toml(json_config: &Value) -> Result<toml::Table> {
|
|
||||||
let mut mcp_servers = toml::Table::new();
|
|
||||||
|
|
||||||
if let Some(obj) = json_config.as_object() {
|
|
||||||
for (server_id, server_spec) in obj {
|
|
||||||
let mut server_table = toml::Table::new();
|
|
||||||
|
|
||||||
if let Some(spec_obj) = server_spec.as_object() {
|
|
||||||
// type 字段(默认 stdio)
|
|
||||||
let server_type = spec_obj
|
|
||||||
.get("type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("stdio");
|
|
||||||
server_table.insert(
|
|
||||||
"type".to_string(),
|
|
||||||
toml::Value::String(server_type.to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
match server_type {
|
|
||||||
"stdio" => {
|
|
||||||
// command 字段(必需)
|
|
||||||
if let Some(cmd) = spec_obj.get("command").and_then(|v| v.as_str()) {
|
|
||||||
server_table.insert(
|
|
||||||
"command".to_string(),
|
|
||||||
toml::Value::String(cmd.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// args 字段(可选)
|
|
||||||
if let Some(args) = spec_obj.get("args").and_then(|v| v.as_array()) {
|
|
||||||
let toml_args: Vec<toml::Value> = args
|
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str())
|
|
||||||
.map(|s| toml::Value::String(s.to_string()))
|
|
||||||
.collect();
|
|
||||||
if !toml_args.is_empty() {
|
|
||||||
server_table
|
|
||||||
.insert("args".to_string(), toml::Value::Array(toml_args));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// env 字段(可选)
|
|
||||||
if let Some(env) = spec_obj.get("env").and_then(|v| v.as_object()) {
|
|
||||||
let mut env_table = toml::Table::new();
|
|
||||||
for (key, value) in env {
|
|
||||||
if let Some(val_str) = value.as_str() {
|
|
||||||
env_table.insert(
|
|
||||||
key.clone(),
|
|
||||||
toml::Value::String(val_str.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !env_table.is_empty() {
|
|
||||||
server_table
|
|
||||||
.insert("env".to_string(), toml::Value::Table(env_table));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cwd 字段(可选)
|
|
||||||
if let Some(cwd) = spec_obj.get("cwd").and_then(|v| v.as_str()) {
|
|
||||||
server_table.insert(
|
|
||||||
"cwd".to_string(),
|
|
||||||
toml::Value::String(cwd.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"http" | "sse" => {
|
|
||||||
// url 字段(必需)
|
|
||||||
if let Some(url) = spec_obj.get("url").and_then(|v| v.as_str()) {
|
|
||||||
server_table.insert(
|
|
||||||
"url".to_string(),
|
|
||||||
toml::Value::String(url.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// http_headers 字段(可选)
|
|
||||||
if let Some(headers) =
|
|
||||||
spec_obj.get("http_headers").and_then(|v| v.as_object())
|
|
||||||
{
|
|
||||||
let mut headers_table = toml::Table::new();
|
|
||||||
for (key, value) in headers {
|
|
||||||
if let Some(val_str) = value.as_str() {
|
|
||||||
headers_table.insert(
|
|
||||||
key.clone(),
|
|
||||||
toml::Value::String(val_str.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !headers_table.is_empty() {
|
|
||||||
server_table.insert(
|
|
||||||
"http_headers".to_string(),
|
|
||||||
toml::Value::Table(headers_table),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mcp_servers.insert(server_id.clone(), toml::Value::Table(server_table));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(mcp_servers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppAdapter for CodexAdapter {
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "agents", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "commands", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
|
||||||
let config_path = self.get_config_toml_path();
|
|
||||||
|
|
||||||
// 读取现有 TOML 配置
|
|
||||||
let mut current = Self::read_toml_file(&config_path)?;
|
|
||||||
|
|
||||||
// 确保 mcp_servers 表存在
|
|
||||||
if !current.contains_key("mcp_servers") {
|
|
||||||
current.insert(
|
|
||||||
"mcp_servers".to_string(),
|
|
||||||
toml::Value::Table(toml::Table::new()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 JSON MCP 配置到 TOML
|
|
||||||
let new_mcp_servers = Self::json_mcp_to_toml(mcp_config)?;
|
|
||||||
|
|
||||||
// 合并 MCP 服务器配置
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcp_servers")
|
|
||||||
.and_then(|v| v.as_table_mut())
|
|
||||||
{
|
|
||||||
for (server_id, server_config) in new_mcp_servers {
|
|
||||||
mcp_servers.insert(server_id, server_config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_toml_file(&config_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Codex MCP 配置到: {}", config_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
|
||||||
bail!("Codex 不支持 Setting 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
|
||||||
bail!("Codex 不支持 Hook 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
|
||||||
match component_type.to_lowercase().as_str() {
|
|
||||||
"agent" => {
|
|
||||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Codex Agent: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"command" => {
|
|
||||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Codex Command: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let config_path = self.get_config_toml_path();
|
|
||||||
let mut current = Self::read_toml_file(&config_path)?;
|
|
||||||
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcp_servers")
|
|
||||||
.and_then(|v| v.as_table_mut())
|
|
||||||
{
|
|
||||||
mcp_servers.remove(name);
|
|
||||||
Self::write_toml_file(&config_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Codex MCP 服务器: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"setting" | "hook" => {
|
|
||||||
bail!("Codex 不支持 {component_type} 组件类型")
|
|
||||||
}
|
|
||||||
_ => bail!("不支持的组件类型: {component_type}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_dir(&self) -> PathBuf {
|
|
||||||
self.config_dir.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
component_type.to_lowercase().as_str(),
|
|
||||||
"agent" | "command" | "mcp"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CodexAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
//! Gemini 应用适配器
|
|
||||||
//!
|
|
||||||
//! 部分支持:
|
|
||||||
//! - Agent → `~/.gemini/agents/{name}.md`
|
|
||||||
//! - Command → `~/.gemini/commands/{name}.md`
|
|
||||||
//! - MCP → 合并到 `~/.gemini/settings.json` 的 mcpServers 字段
|
|
||||||
//! - Setting/Hook → 不支持(Gemini 不支持这些功能)
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::AppAdapter;
|
|
||||||
use crate::config::atomic_write;
|
|
||||||
use crate::gemini_config::{get_gemini_dir, get_gemini_settings_path};
|
|
||||||
|
|
||||||
/// Gemini 应用适配器
|
|
||||||
pub struct GeminiAdapter {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GeminiAdapter {
|
|
||||||
/// 创建新的 Gemini 适配器实例
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
config_dir: get_gemini_dir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
|
||||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(serde_json::json!({}));
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
|
||||||
let value: Value = serde_json::from_str(&content)
|
|
||||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 JSON 配置文件(原子写入)
|
|
||||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
|
||||||
|
|
||||||
atomic_write(path, json.as_bytes())
|
|
||||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 合并两个 JSON 对象(深度合并)
|
|
||||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
|
||||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
|
||||||
for (key, value) in overlay_obj {
|
|
||||||
if let Some(base_value) = base_obj.get_mut(key) {
|
|
||||||
// 如果两边都是对象,递归合并
|
|
||||||
if base_value.is_object() && value.is_object() {
|
|
||||||
Self::merge_json(base_value, value);
|
|
||||||
} else {
|
|
||||||
// 否则直接覆盖
|
|
||||||
*base_value = value.clone();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 键不存在,直接插入
|
|
||||||
base_obj.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Markdown 文件(通用)
|
|
||||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
let dir = self.config_dir.join(subdir);
|
|
||||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
|
||||||
|
|
||||||
let filename = if name.ends_with(".md") {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{name}.md")
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = dir.join(&filename);
|
|
||||||
|
|
||||||
atomic_write(&file_path, content.as_bytes())
|
|
||||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
|
||||||
|
|
||||||
log::info!("已安装 Gemini {}: {}", subdir, file_path.display());
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Gemini settings.json 路径
|
|
||||||
fn get_settings_path(&self) -> PathBuf {
|
|
||||||
get_gemini_settings_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换 MCP 配置为 Gemini 格式
|
|
||||||
///
|
|
||||||
/// Gemini 使用特殊格式:
|
|
||||||
/// - HTTP 类型:使用 `httpUrl` 而不是 `url` + `type: "http"`
|
|
||||||
/// - SSE/stdio 类型:保持标准格式
|
|
||||||
fn transform_mcp_to_gemini(mcp_config: &Value) -> Result<Value> {
|
|
||||||
let mut transformed = mcp_config.clone();
|
|
||||||
|
|
||||||
if let Some(obj) = transformed.as_object_mut() {
|
|
||||||
for (_server_id, server_spec) in obj.iter_mut() {
|
|
||||||
if let Some(spec_obj) = server_spec.as_object_mut() {
|
|
||||||
// 检查是否为 HTTP 类型
|
|
||||||
let is_http = spec_obj
|
|
||||||
.get("type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|t| t == "http")
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if is_http {
|
|
||||||
// 将 url 字段转换为 httpUrl
|
|
||||||
if let Some(url) = spec_obj.remove("url") {
|
|
||||||
spec_obj.insert("httpUrl".to_string(), url);
|
|
||||||
}
|
|
||||||
// 移除 type 字段(Gemini 不需要显式指定 type)
|
|
||||||
spec_obj.remove("type");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(transformed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppAdapter for GeminiAdapter {
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "agents", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "commands", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
|
|
||||||
// 读取现有配置
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 确保 mcpServers 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("mcpServers").is_none() {
|
|
||||||
current["mcpServers"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 MCP 配置为 Gemini 格式
|
|
||||||
let transformed = Self::transform_mcp_to_gemini(mcp_config)?;
|
|
||||||
|
|
||||||
// 合并新的 MCP 服务器配置
|
|
||||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
|
||||||
Self::merge_json(mcp_servers, &transformed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Gemini MCP 配置到: {}", settings_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
|
||||||
bail!("Gemini 不支持 Setting 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
|
||||||
bail!("Gemini 不支持 Hook 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
|
||||||
match component_type.to_lowercase().as_str() {
|
|
||||||
"agent" => {
|
|
||||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Gemini Agent: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"command" => {
|
|
||||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Gemini Command: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcpServers")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
mcp_servers.remove(name);
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Gemini MCP 服务器: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"setting" | "hook" => {
|
|
||||||
bail!("Gemini 不支持 {component_type} 组件类型")
|
|
||||||
}
|
|
||||||
_ => bail!("不支持的组件类型: {component_type}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_dir(&self) -> PathBuf {
|
|
||||||
self.config_dir.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
component_type.to_lowercase().as_str(),
|
|
||||||
"agent" | "command" | "mcp"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GeminiAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
//! 应用适配器模块
|
|
||||||
//!
|
|
||||||
//! 负责将 Template 组件安装到不同应用的配置目录中。
|
|
||||||
//! 每个应用有独立的适配器实现,处理各自的配置格式和目录结构。
|
|
||||||
|
|
||||||
mod claude;
|
|
||||||
mod codex;
|
|
||||||
mod gemini;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub use claude::ClaudeAdapter;
|
|
||||||
pub use codex::CodexAdapter;
|
|
||||||
pub use gemini::GeminiAdapter;
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
|
|
||||||
/// 应用适配器 trait
|
|
||||||
///
|
|
||||||
/// 定义了将 Template 组件安装到应用配置目录的统一接口。
|
|
||||||
/// 每个应用实现自己的适配器来处理特定的配置格式和目录结构。
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub trait AppAdapter: Send + Sync {
|
|
||||||
/// 安装 Agent 到应用配置目录
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `content`: Agent 内容(Markdown 格式)
|
|
||||||
/// - `name`: Agent 名称(用作文件名)
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 安装后的文件路径
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf>;
|
|
||||||
|
|
||||||
/// 安装 Command 到应用配置目录
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `content`: Command 内容(Markdown 格式)
|
|
||||||
/// - `name`: Command 名称(用作文件名)
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 安装后的文件路径
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf>;
|
|
||||||
|
|
||||||
/// 安装 MCP 服务器配置
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `mcp_config`: MCP 服务器配置(JSON 对象)
|
|
||||||
///
|
|
||||||
/// # 说明
|
|
||||||
/// 配置会合并到应用的 MCP 配置文件中,保留现有配置。
|
|
||||||
fn install_mcp(&self, mcp_config: &serde_json::Value) -> Result<()>;
|
|
||||||
|
|
||||||
/// 安装 Setting (permissions)
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `setting_config`: Setting 配置(JSON 对象)
|
|
||||||
///
|
|
||||||
/// # 说明
|
|
||||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 permissions 字段。
|
|
||||||
fn install_setting(&self, setting_config: &serde_json::Value) -> Result<()>;
|
|
||||||
|
|
||||||
/// 安装 Hook
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `hook_config`: Hook 配置(JSON 对象)
|
|
||||||
///
|
|
||||||
/// # 说明
|
|
||||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 hooks 字段。
|
|
||||||
fn install_hook(&self, hook_config: &serde_json::Value) -> Result<()>;
|
|
||||||
|
|
||||||
/// 卸载组件
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `component_type`: 组件类型(agent/command/mcp/setting/hook)
|
|
||||||
/// - `name`: 组件名称或 ID
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()>;
|
|
||||||
|
|
||||||
/// 获取配置目录路径
|
|
||||||
fn config_dir(&self) -> PathBuf;
|
|
||||||
|
|
||||||
/// 检查组件类型是否支持
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `component_type`: 组件类型字符串
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 如果应用支持该组件类型返回 true,否则返回 false
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建应用适配器工厂函数
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `app_type`: 应用类型
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 对应应用的适配器实例
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn create_adapter(app_type: &AppType) -> Box<dyn AppAdapter> {
|
|
||||||
match app_type {
|
|
||||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
|
||||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
|
||||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use tokio::time::timeout;
|
|
||||||
|
|
||||||
use super::{ComponentMetadata, ComponentType, TemplateComponent, TemplateRepo, TemplateService};
|
|
||||||
|
|
||||||
impl TemplateService {
|
|
||||||
/// 刷新所有启用仓库的组件索引
|
|
||||||
pub async fn refresh_index(&self, conn: &Connection) -> Result<()> {
|
|
||||||
// 获取所有启用的仓库
|
|
||||||
let repos = self.list_enabled_repos(conn)?;
|
|
||||||
|
|
||||||
if repos.is_empty() {
|
|
||||||
log::info!("没有启用的模板仓库");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("开始刷新 {} 个模板仓库", repos.len());
|
|
||||||
|
|
||||||
// 并行扫描所有仓库
|
|
||||||
let scan_tasks = repos.iter().map(|repo| self.scan_repo(repo));
|
|
||||||
let results: Vec<Result<Vec<TemplateComponent>>> =
|
|
||||||
futures::future::join_all(scan_tasks).await;
|
|
||||||
|
|
||||||
// 处理扫描结果
|
|
||||||
let mut total_components = 0;
|
|
||||||
for (repo, result) in repos.iter().zip(results.into_iter()) {
|
|
||||||
match result {
|
|
||||||
Ok(components) => {
|
|
||||||
log::info!(
|
|
||||||
"仓库 {}/{} 扫描到 {} 个组件",
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
components.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 保存到数据库
|
|
||||||
if let Err(e) = self.save_components(conn, &components) {
|
|
||||||
log::error!("保存组件到数据库失败: {e}");
|
|
||||||
} else {
|
|
||||||
total_components += components.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("扫描仓库 {}/{} 失败: {}", repo.owner, repo.name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("刷新完成,共索引 {total_components} 个组件");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描单个仓库
|
|
||||||
pub async fn scan_repo(&self, repo: &TemplateRepo) -> Result<Vec<TemplateComponent>> {
|
|
||||||
log::info!("开始扫描仓库: {}/{}", repo.owner, repo.name);
|
|
||||||
|
|
||||||
// 下载仓库(增加超时控制)
|
|
||||||
let temp_dir = timeout(
|
|
||||||
std::time::Duration::from_secs(120),
|
|
||||||
self.download_repo(repo),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow!("下载仓库超时: {}/{}", repo.owner, repo.name))??;
|
|
||||||
|
|
||||||
let mut components = Vec::new();
|
|
||||||
|
|
||||||
// 扫描不同类型的组件
|
|
||||||
self.scan_agents(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_commands(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_mcps(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_settings(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_hooks(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_skills(&temp_dir, repo, &mut components)?;
|
|
||||||
|
|
||||||
// 清理临时目录
|
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"仓库 {}/{} 扫描完成,找到 {} 个组件",
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
components.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(components)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载仓库 ZIP
|
|
||||||
async fn download_repo(&self, repo: &TemplateRepo) -> Result<PathBuf> {
|
|
||||||
let temp_dir = tempfile::tempdir().context("创建临时目录失败")?;
|
|
||||||
let temp_path = temp_dir.path().to_path_buf();
|
|
||||||
let _ = temp_dir.keep();
|
|
||||||
|
|
||||||
// 尝试多个分支
|
|
||||||
let branches = if repo.branch.is_empty() {
|
|
||||||
vec!["main", "master"]
|
|
||||||
} else {
|
|
||||||
vec![repo.branch.as_str(), "main", "master"]
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut last_error = None;
|
|
||||||
for branch in branches {
|
|
||||||
let url = format!(
|
|
||||||
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
|
||||||
repo.owner, repo.name, branch
|
|
||||||
);
|
|
||||||
|
|
||||||
log::debug!("尝试下载: {url}");
|
|
||||||
match self.download_and_extract(&url, &temp_path).await {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!("成功下载仓库: {}/{} ({})", repo.owner, repo.name, branch);
|
|
||||||
return Ok(temp_path);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::debug!("下载分支 {branch} 失败: {e}");
|
|
||||||
last_error = Some(e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(last_error.unwrap_or_else(|| anyhow!("所有分支下载失败")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载并解压 ZIP
|
|
||||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
|
||||||
// 下载 ZIP
|
|
||||||
let response = self.client().get(url).send().await?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("下载失败: HTTP {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = response.bytes().await?;
|
|
||||||
|
|
||||||
// 解压
|
|
||||||
let cursor = std::io::Cursor::new(bytes);
|
|
||||||
let mut archive = zip::ZipArchive::new(cursor)?;
|
|
||||||
|
|
||||||
// 获取根目录名称
|
|
||||||
let root_name = if !archive.is_empty() {
|
|
||||||
let first_file = archive.by_index(0)?;
|
|
||||||
let name = first_file.name();
|
|
||||||
name.split('/').next().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
return Err(anyhow!("空的压缩包"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 解压所有文件
|
|
||||||
for i in 0..archive.len() {
|
|
||||||
let mut file = archive.by_index(i)?;
|
|
||||||
let file_path = file.name();
|
|
||||||
|
|
||||||
// 跳过根目录,直接提取内容
|
|
||||||
let relative_path =
|
|
||||||
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
|
||||||
stripped
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if relative_path.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let outpath = dest.join(relative_path);
|
|
||||||
|
|
||||||
if file.is_dir() {
|
|
||||||
fs::create_dir_all(&outpath)?;
|
|
||||||
} else {
|
|
||||||
if let Some(parent) = outpath.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let mut outfile = fs::File::create(&outpath)?;
|
|
||||||
std::io::copy(&mut file, &mut outfile)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Agents
|
|
||||||
fn scan_agents(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
// 尝试多个可能的路径
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("agents"),
|
|
||||||
base_dir.join("src").join("agents"),
|
|
||||||
base_dir.join("components").join("agents"),
|
|
||||||
];
|
|
||||||
for agents_dir in paths {
|
|
||||||
if agents_dir.exists() {
|
|
||||||
self.scan_markdown_components(
|
|
||||||
&agents_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Agent,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Commands
|
|
||||||
fn scan_commands(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir
|
|
||||||
.join("cli-tool")
|
|
||||||
.join("components")
|
|
||||||
.join("commands"),
|
|
||||||
base_dir.join("src").join("commands"),
|
|
||||||
base_dir.join("components").join("commands"),
|
|
||||||
];
|
|
||||||
for commands_dir in paths {
|
|
||||||
if commands_dir.exists() {
|
|
||||||
self.scan_markdown_components(
|
|
||||||
&commands_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Command,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 MCPs
|
|
||||||
fn scan_mcps(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("mcps"),
|
|
||||||
base_dir.join("src").join("mcp"),
|
|
||||||
base_dir.join("components").join("mcps"),
|
|
||||||
];
|
|
||||||
for mcps_dir in paths {
|
|
||||||
if mcps_dir.exists() {
|
|
||||||
self.scan_json_components(
|
|
||||||
&mcps_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Mcp,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Settings
|
|
||||||
fn scan_settings(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir
|
|
||||||
.join("cli-tool")
|
|
||||||
.join("components")
|
|
||||||
.join("settings"),
|
|
||||||
base_dir.join("src").join("settings"),
|
|
||||||
base_dir.join("components").join("settings"),
|
|
||||||
];
|
|
||||||
for settings_dir in paths {
|
|
||||||
if settings_dir.exists() {
|
|
||||||
self.scan_json_components(
|
|
||||||
&settings_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Setting,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Hooks
|
|
||||||
fn scan_hooks(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("hooks"),
|
|
||||||
base_dir.join("src").join("hooks"),
|
|
||||||
base_dir.join("components").join("hooks"),
|
|
||||||
];
|
|
||||||
for hooks_dir in paths {
|
|
||||||
if hooks_dir.exists() {
|
|
||||||
self.scan_json_components(
|
|
||||||
&hooks_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Hook,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Skills
|
|
||||||
fn scan_skills(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("skills"),
|
|
||||||
base_dir.join("src").join("skills"),
|
|
||||||
base_dir.join("components").join("skills"),
|
|
||||||
];
|
|
||||||
for skills_dir in paths {
|
|
||||||
if skills_dir.exists() {
|
|
||||||
self.scan_skills_recursive(&skills_dir, base_dir, repo, components)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Markdown 组件(Agent/Command)
|
|
||||||
fn scan_markdown_components(
|
|
||||||
&self,
|
|
||||||
dir: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
|
|
||||||
if let Ok(component) =
|
|
||||||
self.parse_markdown_component(&path, base_dir, component_type.clone(), repo)
|
|
||||||
{
|
|
||||||
components.push(component);
|
|
||||||
}
|
|
||||||
} else if path.is_dir() {
|
|
||||||
// 递归扫描子目录(用于分类)
|
|
||||||
self.scan_markdown_components(
|
|
||||||
&path,
|
|
||||||
base_dir,
|
|
||||||
component_type.clone(),
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 JSON 组件(MCP/Setting/Hook)
|
|
||||||
fn scan_json_components(
|
|
||||||
&self,
|
|
||||||
dir: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
|
|
||||||
if let Ok(component) =
|
|
||||||
self.parse_json_component(&path, base_dir, component_type.clone(), repo)
|
|
||||||
{
|
|
||||||
components.push(component);
|
|
||||||
}
|
|
||||||
} else if path.is_dir() {
|
|
||||||
// 递归扫描子目录(用于分类)
|
|
||||||
self.scan_json_components(
|
|
||||||
&path,
|
|
||||||
base_dir,
|
|
||||||
component_type.clone(),
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归扫描技能目录
|
|
||||||
fn scan_skills_recursive(
|
|
||||||
&self,
|
|
||||||
current_dir: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let skill_md = current_dir.join("SKILL.md");
|
|
||||||
|
|
||||||
if skill_md.exists() {
|
|
||||||
// 发现技能
|
|
||||||
if let Ok(component) = self.parse_skill_component(&skill_md, base_dir, repo) {
|
|
||||||
components.push(component);
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 继续递归扫描子目录
|
|
||||||
for entry in fs::read_dir(current_dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
self.scan_skills_recursive(&path, base_dir, repo, components)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 Markdown 组件元数据
|
|
||||||
fn parse_markdown_component(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
) -> Result<TemplateComponent> {
|
|
||||||
let content = fs::read_to_string(path)?;
|
|
||||||
let meta = self.parse_component_metadata(&content)?;
|
|
||||||
|
|
||||||
let file_name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
// 提取分类(从目录结构)
|
|
||||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
|
||||||
|
|
||||||
// 计算相对于仓库根目录的路径
|
|
||||||
let relative_path = path
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.unwrap_or(path)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: None,
|
|
||||||
repo_id: repo.id.unwrap_or(0),
|
|
||||||
component_type,
|
|
||||||
category,
|
|
||||||
name: meta.name.unwrap_or_else(|| file_name.to_string()),
|
|
||||||
path: relative_path,
|
|
||||||
description: meta.description,
|
|
||||||
content_hash: Some(Self::calculate_hash(&content)),
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 JSON 组件元数据
|
|
||||||
fn parse_json_component(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
) -> Result<TemplateComponent> {
|
|
||||||
let content = fs::read_to_string(path)?;
|
|
||||||
let json: serde_json::Value = serde_json::from_str(&content)?;
|
|
||||||
|
|
||||||
let file_name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
let name = json
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or(file_name)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let description = json
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(String::from);
|
|
||||||
|
|
||||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
|
||||||
|
|
||||||
// 计算相对于仓库根目录的路径
|
|
||||||
let relative_path = path
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.unwrap_or(path)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: None,
|
|
||||||
repo_id: repo.id.unwrap_or(0),
|
|
||||||
component_type,
|
|
||||||
category,
|
|
||||||
name,
|
|
||||||
path: relative_path,
|
|
||||||
description,
|
|
||||||
content_hash: Some(Self::calculate_hash(&content)),
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析技能组件
|
|
||||||
fn parse_skill_component(
|
|
||||||
&self,
|
|
||||||
skill_md: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
) -> Result<TemplateComponent> {
|
|
||||||
let content = fs::read_to_string(skill_md)?;
|
|
||||||
let meta = self.parse_component_metadata(&content)?;
|
|
||||||
|
|
||||||
let skill_dir = skill_md.parent().unwrap();
|
|
||||||
let directory = skill_dir
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.unwrap_or(skill_dir)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: None,
|
|
||||||
repo_id: repo.id.unwrap_or(0),
|
|
||||||
component_type: ComponentType::Skill,
|
|
||||||
category: None,
|
|
||||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
|
||||||
path: directory,
|
|
||||||
description: meta.description,
|
|
||||||
content_hash: Some(Self::calculate_hash(&content)),
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析组件元数据(从 front matter)
|
|
||||||
pub fn parse_component_metadata(&self, content: &str) -> Result<ComponentMetadata> {
|
|
||||||
// 移除 BOM
|
|
||||||
let content = content.trim_start_matches('\u{feff}');
|
|
||||||
|
|
||||||
// 提取 YAML front matter
|
|
||||||
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
|
||||||
if parts.len() < 3 {
|
|
||||||
return Ok(ComponentMetadata {
|
|
||||||
name: None,
|
|
||||||
description: None,
|
|
||||||
tools: None,
|
|
||||||
model: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let front_matter = parts[1].trim();
|
|
||||||
let meta: ComponentMetadata =
|
|
||||||
serde_yaml::from_str(front_matter).unwrap_or(ComponentMetadata {
|
|
||||||
name: None,
|
|
||||||
description: None,
|
|
||||||
tools: None,
|
|
||||||
model: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提取分类(从路径)
|
|
||||||
fn extract_category(&self, path: &Path, base: &str) -> Option<String> {
|
|
||||||
let path_str = path.to_string_lossy();
|
|
||||||
if let Some(pos) = path_str.find(base) {
|
|
||||||
let after_base = &path_str[pos + base.len()..];
|
|
||||||
let parts: Vec<&str> = after_base.split('/').filter(|s| !s.is_empty()).collect();
|
|
||||||
if parts.len() > 1 {
|
|
||||||
return Some(parts[0].to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 计算内容哈希
|
|
||||||
fn calculate_hash(content: &str) -> String {
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(content.as_bytes());
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存组件到数据库
|
|
||||||
fn save_components(&self, conn: &Connection, components: &[TemplateComponent]) -> Result<()> {
|
|
||||||
for component in components {
|
|
||||||
// 检查是否已存在(通过 repo_id + component_type + path)
|
|
||||||
let existing: Option<i64> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM template_components
|
|
||||||
WHERE repo_id = ?1 AND component_type = ?2 AND path = ?3",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
&component.path
|
|
||||||
],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
if let Some(id) = existing {
|
|
||||||
// 更新现有组件
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_components
|
|
||||||
SET category = ?1, name = ?2, description = ?3, content_hash = ?4, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?5",
|
|
||||||
params![
|
|
||||||
&component.category,
|
|
||||||
&component.name,
|
|
||||||
&component.description,
|
|
||||||
&component.content_hash,
|
|
||||||
id
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
} else {
|
|
||||||
// 插入新组件
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_components (repo_id, component_type, category, name, path, description, content_hash)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
&component.category,
|
|
||||||
&component.name,
|
|
||||||
&component.path,
|
|
||||||
&component.description,
|
|
||||||
&component.content_hash
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出组件(支持过滤和分页)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn list_components(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
component_type: Option<ComponentType>,
|
|
||||||
category: Option<String>,
|
|
||||||
search: Option<String>,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
) -> Result<super::PaginatedResult<TemplateComponent>> {
|
|
||||||
let mut where_clauses = Vec::new();
|
|
||||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(ct) = &component_type {
|
|
||||||
where_clauses.push("component_type = ?");
|
|
||||||
params.push(Box::new(ct.as_str().to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cat) = &category {
|
|
||||||
where_clauses.push("category = ?");
|
|
||||||
params.push(Box::new(cat.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(s) = &search {
|
|
||||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
|
||||||
let search_pattern = format!("%{s}%");
|
|
||||||
params.push(Box::new(search_pattern.clone()));
|
|
||||||
params.push(Box::new(search_pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_sql = if where_clauses.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", where_clauses.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取总数
|
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
|
||||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
let total: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
|
|
||||||
|
|
||||||
// 获取分页数据
|
|
||||||
let offset = (page - 1) * page_size;
|
|
||||||
let query_sql = format!(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY name
|
|
||||||
LIMIT ? OFFSET ?"
|
|
||||||
);
|
|
||||||
|
|
||||||
params.push(Box::new(page_size as i64));
|
|
||||||
params.push(Box::new(offset as i64));
|
|
||||||
|
|
||||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&query_sql)?;
|
|
||||||
let components = stmt
|
|
||||||
.query_map(param_refs.as_slice(), |row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
|
|
||||||
Ok(super::PaginatedResult {
|
|
||||||
items: components,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
BatchInstallResult, ComponentDetail, ComponentType, InstalledComponent, TemplateComponent,
|
|
||||||
TemplateService,
|
|
||||||
};
|
|
||||||
|
|
||||||
impl TemplateService {
|
|
||||||
/// 获取组件详情(含完整内容)
|
|
||||||
pub async fn get_component(&self, conn: &Connection, id: i64) -> Result<ComponentDetail> {
|
|
||||||
// 查询组件基本信息
|
|
||||||
let component: TemplateComponent = conn.query_row(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 查询仓库信息
|
|
||||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
|
||||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
|
||||||
params![component.repo_id],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 构建 README URL
|
|
||||||
let readme_url = format!(
|
|
||||||
"https://github.com/{}/{}/tree/{}/{}",
|
|
||||||
repo_owner, repo_name, branch, component.path
|
|
||||||
);
|
|
||||||
|
|
||||||
// 下载并读取组件内容
|
|
||||||
let content = self
|
|
||||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(ComponentDetail {
|
|
||||||
component,
|
|
||||||
content,
|
|
||||||
repo_owner,
|
|
||||||
repo_name,
|
|
||||||
repo_branch: branch,
|
|
||||||
readme_url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载组件内容
|
|
||||||
async fn download_component_content(
|
|
||||||
&self,
|
|
||||||
owner: &str,
|
|
||||||
name: &str,
|
|
||||||
branch: &str,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
let url = format!("https://raw.githubusercontent.com/{owner}/{name}/{branch}/{path}");
|
|
||||||
|
|
||||||
let response = self.client().get(&url).send().await?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("下载组件内容失败: HTTP {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = response.text().await?;
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装组件到指定应用
|
|
||||||
pub async fn install_component(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
id: i64,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
// 获取组件详情
|
|
||||||
let detail = self.get_component(conn, id).await?;
|
|
||||||
|
|
||||||
// 根据组件类型执行不同的安装逻辑
|
|
||||||
match detail.component.component_type {
|
|
||||||
ComponentType::Agent => {
|
|
||||||
self.install_agent(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Command => {
|
|
||||||
self.install_command(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Mcp => {
|
|
||||||
self.install_mcp(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Setting => {
|
|
||||||
self.install_setting(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Hook => {
|
|
||||||
self.install_hook(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Skill => {
|
|
||||||
self.install_skill(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录安装状态
|
|
||||||
self.record_installation(conn, &detail.component, app_type)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Agent
|
|
||||||
async fn install_agent(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let agents_dir = config_dir.join("agents");
|
|
||||||
fs::create_dir_all(&agents_dir)?;
|
|
||||||
|
|
||||||
let file_name = format!("{}.md", detail.component.name);
|
|
||||||
let dest_path = agents_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!("Agent 已安装: {}", dest_path.display());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Command
|
|
||||||
async fn install_command(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let commands_dir = config_dir.join("commands");
|
|
||||||
fs::create_dir_all(&commands_dir)?;
|
|
||||||
|
|
||||||
let file_name = format!("{}.md", detail.component.name);
|
|
||||||
let dest_path = commands_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!("Command 已安装: {}", dest_path.display());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 MCP 服务器
|
|
||||||
/// MCP 配置保存为独立 JSON 文件到 mcps/ 目录,不会修改原有 .mcp.json
|
|
||||||
async fn install_mcp(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let mcps_dir = config_dir.join("mcps");
|
|
||||||
fs::create_dir_all(&mcps_dir)?;
|
|
||||||
|
|
||||||
// 保存为独立的 JSON 文件(保留原始格式,包含 mcpServers 结构)
|
|
||||||
let file_name = format!("{}.json", detail.component.name);
|
|
||||||
let dest_path = mcps_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!(
|
|
||||||
"MCP 配置已保存: {} (可手动合并到 .mcp.json)",
|
|
||||||
dest_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Setting
|
|
||||||
/// Setting 配置保存为独立 JSON 文件到 settings/ 目录,不会修改原有 settings.json
|
|
||||||
/// 原始格式包含 permissions 等配置,可手动合并
|
|
||||||
async fn install_setting(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let settings_dir = config_dir.join("settings");
|
|
||||||
fs::create_dir_all(&settings_dir)?;
|
|
||||||
|
|
||||||
// 保存为独立的 JSON 文件(保留原始格式,包含 permissions 等结构)
|
|
||||||
let file_name = format!("{}.json", detail.component.name);
|
|
||||||
let dest_path = settings_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!(
|
|
||||||
"Setting 配置已保存: {} (可手动合并到 settings.json)",
|
|
||||||
dest_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Hook
|
|
||||||
/// Hook 配置保存为独立 JSON 文件到 hooks/ 目录,不会修改原有 settings.json
|
|
||||||
/// 原始格式包含 hooks 对象(如 PostToolUse 等),可手动合并
|
|
||||||
async fn install_hook(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let hooks_dir = config_dir.join("hooks");
|
|
||||||
fs::create_dir_all(&hooks_dir)?;
|
|
||||||
|
|
||||||
// 保存为独立的 JSON 文件(保留原始格式,包含 hooks 结构)
|
|
||||||
let file_name = format!("{}.json", detail.component.name);
|
|
||||||
let dest_path = hooks_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!(
|
|
||||||
"Hook 配置已保存: {} (可手动合并到 settings.json 的 hooks 字段)",
|
|
||||||
dest_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Skill
|
|
||||||
/// Skill 是一个目录结构,包含 SKILL.md 和可能的子目录(如 reference/, scripts/)
|
|
||||||
/// 使用 GitHub API 递归下载整个目录
|
|
||||||
async fn install_skill(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let skills_dir = config_dir.join("skills");
|
|
||||||
fs::create_dir_all(&skills_dir)?;
|
|
||||||
|
|
||||||
let skill_dir = skills_dir.join(&detail.component.name);
|
|
||||||
fs::create_dir_all(&skill_dir)?;
|
|
||||||
|
|
||||||
// 首先保存 SKILL.md(已下载的内容)
|
|
||||||
let skill_md = skill_dir.join("SKILL.md");
|
|
||||||
fs::write(&skill_md, &detail.content)?;
|
|
||||||
|
|
||||||
// 尝试下载整个 skill 目录的其他文件
|
|
||||||
// 构建 GitHub API URL 来获取目录内容
|
|
||||||
let api_url = format!(
|
|
||||||
"https://api.github.com/repos/{}/{}/contents/{}",
|
|
||||||
detail.repo_owner,
|
|
||||||
detail.repo_name,
|
|
||||||
detail.component.path.trim_end_matches("/SKILL.md")
|
|
||||||
);
|
|
||||||
|
|
||||||
// 递归下载目录内容
|
|
||||||
if let Err(e) = self
|
|
||||||
.download_skill_directory(&api_url, &skill_dir, &detail.repo_branch)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::warn!("下载 Skill 附加文件失败: {e},仅安装 SKILL.md");
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Skill 已安装: {}", skill_dir.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归下载 Skill 目录内容
|
|
||||||
async fn download_skill_directory(
|
|
||||||
&self,
|
|
||||||
api_url: &str,
|
|
||||||
target_dir: &std::path::Path,
|
|
||||||
branch: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let response = self
|
|
||||||
.client()
|
|
||||||
.get(api_url)
|
|
||||||
.header("Accept", "application/vnd.github.v3+json")
|
|
||||||
.header("User-Agent", "cc-switch")
|
|
||||||
.query(&[("ref", branch)])
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("GitHub API 请求失败: {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let contents: Vec<serde_json::Value> = response.json().await?;
|
|
||||||
|
|
||||||
for item in contents {
|
|
||||||
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
let item_name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
|
|
||||||
// 跳过 SKILL.md(已经下载)
|
|
||||||
if item_name == "SKILL.md" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if item_type == "file" {
|
|
||||||
// 下载文件
|
|
||||||
if let Some(download_url) = item.get("download_url").and_then(|v| v.as_str()) {
|
|
||||||
let file_response = self.client().get(download_url).send().await?;
|
|
||||||
if file_response.status().is_success() {
|
|
||||||
let content = file_response.text().await?;
|
|
||||||
let file_path = target_dir.join(item_name);
|
|
||||||
fs::write(&file_path, &content)?;
|
|
||||||
log::debug!("下载文件: {}", file_path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if item_type == "dir" {
|
|
||||||
// 递归下载子目录
|
|
||||||
if let Some(sub_url) = item.get("url").and_then(|v| v.as_str()) {
|
|
||||||
let sub_dir = target_dir.join(item_name);
|
|
||||||
fs::create_dir_all(&sub_dir)?;
|
|
||||||
// 递归调用,使用 Box::pin 处理异步递归
|
|
||||||
Box::pin(self.download_skill_directory(sub_url, &sub_dir, branch)).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 记录安装状态
|
|
||||||
fn record_installation(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
component: &TemplateComponent,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO installed_components (component_id, component_type, name, path, app_type)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
||||||
params![
|
|
||||||
component.id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
&component.name,
|
|
||||||
&component.path,
|
|
||||||
app_type
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 卸载组件
|
|
||||||
pub fn uninstall_component(&self, conn: &Connection, id: i64, app_type: &str) -> Result<()> {
|
|
||||||
// 查询组件信息
|
|
||||||
let component: TemplateComponent = conn.query_row(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 删除文件
|
|
||||||
match component.component_type {
|
|
||||||
ComponentType::Agent => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("agents")
|
|
||||||
.join(format!("{}.md", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Command => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("commands")
|
|
||||||
.join(format!("{}.md", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Skill => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let skill_dir = config_dir.join("skills").join(&component.name);
|
|
||||||
if skill_dir.exists() {
|
|
||||||
fs::remove_dir_all(&skill_dir)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Mcp => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("mcps")
|
|
||||||
.join(format!("{}.json", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Setting => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("settings")
|
|
||||||
.join(format!("{}.json", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Hook => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("hooks")
|
|
||||||
.join(format!("{}.json", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除安装记录
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM installed_components
|
|
||||||
WHERE component_id = ?1 AND app_type = ?2",
|
|
||||||
params![id, app_type],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
log::info!("组件已卸载: {}", component.name);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量安装组件
|
|
||||||
pub async fn batch_install(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
ids: Vec<i64>,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<BatchInstallResult> {
|
|
||||||
let mut success = Vec::new();
|
|
||||||
let mut failed = Vec::new();
|
|
||||||
|
|
||||||
for id in ids {
|
|
||||||
match self.install_component(conn, id, app_type).await {
|
|
||||||
Ok(_) => success.push(id),
|
|
||||||
Err(e) => failed.push((id, e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(BatchInstallResult { success, failed })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出已安装的组件
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn list_installed(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
app_type: Option<&str>,
|
|
||||||
) -> Result<Vec<InstalledComponent>> {
|
|
||||||
let (sql, params): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type {
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
WHERE app_type = ?
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![Box::new(at.to_string())],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
|
||||||
let components = stmt
|
|
||||||
.query_map(param_refs.as_slice(), |row| {
|
|
||||||
Ok(InstalledComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
component_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
name: row.get(3)?,
|
|
||||||
path: row.get(4)?,
|
|
||||||
app_type: row.get(5)?,
|
|
||||||
installed_at: row
|
|
||||||
.get::<_, String>(6)?
|
|
||||||
.parse()
|
|
||||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
|
|
||||||
Ok(components)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 预览组件内容(仅获取内容,不进行安装)
|
|
||||||
pub async fn preview_content(&self, conn: &Connection, id: i64) -> Result<String> {
|
|
||||||
// 查询组件基本信息
|
|
||||||
let component: TemplateComponent = conn.query_row(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 查询仓库信息
|
|
||||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
|
||||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
|
||||||
params![component.repo_id],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 下载并读取组件内容
|
|
||||||
let content = self
|
|
||||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub mod adapters;
|
|
||||||
pub mod index;
|
|
||||||
pub mod installer;
|
|
||||||
pub mod repo;
|
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use adapters::{create_adapter, AppAdapter};
|
|
||||||
|
|
||||||
/// 组件类型枚举
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum ComponentType {
|
|
||||||
Agent,
|
|
||||||
Command,
|
|
||||||
Mcp,
|
|
||||||
Setting,
|
|
||||||
Hook,
|
|
||||||
Skill,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ComponentType {
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
ComponentType::Agent => "agent",
|
|
||||||
ComponentType::Command => "command",
|
|
||||||
ComponentType::Mcp => "mcp",
|
|
||||||
ComponentType::Setting => "setting",
|
|
||||||
ComponentType::Hook => "hook",
|
|
||||||
ComponentType::Skill => "skill",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"agent" => Some(ComponentType::Agent),
|
|
||||||
"command" => Some(ComponentType::Command),
|
|
||||||
"mcp" => Some(ComponentType::Mcp),
|
|
||||||
"setting" => Some(ComponentType::Setting),
|
|
||||||
"hook" => Some(ComponentType::Hook),
|
|
||||||
"skill" => Some(ComponentType::Skill),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模板仓库
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TemplateRepo {
|
|
||||||
pub id: Option<i64>,
|
|
||||||
pub owner: String,
|
|
||||||
pub name: String,
|
|
||||||
pub branch: String,
|
|
||||||
pub enabled: bool,
|
|
||||||
#[serde(rename = "createdAt")]
|
|
||||||
pub created_at: Option<DateTime<Utc>>,
|
|
||||||
#[serde(rename = "updatedAt")]
|
|
||||||
pub updated_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TemplateRepo {
|
|
||||||
pub fn new(owner: String, name: String, branch: String) -> Self {
|
|
||||||
Self {
|
|
||||||
id: None,
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
branch,
|
|
||||||
enabled: true,
|
|
||||||
created_at: None,
|
|
||||||
updated_at: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模板组件
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TemplateComponent {
|
|
||||||
pub id: Option<i64>,
|
|
||||||
#[serde(rename = "repoId")]
|
|
||||||
pub repo_id: i64,
|
|
||||||
#[serde(rename = "componentType")]
|
|
||||||
pub component_type: ComponentType,
|
|
||||||
pub category: Option<String>,
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(rename = "contentHash")]
|
|
||||||
pub content_hash: Option<String>,
|
|
||||||
/// 是否已安装(前端展示用,需要在查询时填充)
|
|
||||||
pub installed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 组件详情(含完整内容)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ComponentDetail {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub component: TemplateComponent,
|
|
||||||
/// 完整文件内容
|
|
||||||
pub content: String,
|
|
||||||
/// 仓库所有者
|
|
||||||
#[serde(rename = "repoOwner")]
|
|
||||||
pub repo_owner: String,
|
|
||||||
/// 仓库名称
|
|
||||||
#[serde(rename = "repoName")]
|
|
||||||
pub repo_name: String,
|
|
||||||
/// 仓库分支
|
|
||||||
#[serde(rename = "repoBranch")]
|
|
||||||
pub repo_branch: String,
|
|
||||||
/// GitHub README URL
|
|
||||||
#[serde(rename = "readmeUrl")]
|
|
||||||
pub readme_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 组件元数据(从文件 front matter 解析)
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ComponentMetadata {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
/// Agent 专用 - 工具列表
|
|
||||||
pub tools: Option<String>,
|
|
||||||
/// Agent 专用 - 模型名称
|
|
||||||
pub model: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 分页结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PaginatedResult<T> {
|
|
||||||
pub items: Vec<T>,
|
|
||||||
pub total: i64,
|
|
||||||
pub page: u32,
|
|
||||||
#[serde(rename = "pageSize")]
|
|
||||||
pub page_size: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量安装结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BatchInstallResult {
|
|
||||||
pub success: Vec<i64>,
|
|
||||||
pub failed: Vec<(i64, String)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 已安装组件
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct InstalledComponent {
|
|
||||||
pub id: Option<i64>,
|
|
||||||
#[serde(rename = "componentId")]
|
|
||||||
pub component_id: Option<i64>,
|
|
||||||
#[serde(rename = "componentType")]
|
|
||||||
pub component_type: ComponentType,
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
#[serde(rename = "appType")]
|
|
||||||
pub app_type: String,
|
|
||||||
#[serde(rename = "installedAt")]
|
|
||||||
pub installed_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 市场组合项(plugin 中的单个组件)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MarketplaceBundleItem {
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
#[serde(rename = "componentType")]
|
|
||||||
pub component_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 市场组合
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MarketplaceBundle {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub category: String,
|
|
||||||
pub components: Vec<MarketplaceBundleItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Template 服务
|
|
||||||
pub struct TemplateService {
|
|
||||||
http_client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TemplateService {
|
|
||||||
pub fn new() -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
http_client: Client::builder()
|
|
||||||
.user_agent("cc-switch")
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.context("创建 HTTP 客户端失败")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 HTTP 客户端
|
|
||||||
pub fn client(&self) -> &Client {
|
|
||||||
&self.http_client
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取应用配置目录
|
|
||||||
pub fn get_app_config_dir(app_type: &str) -> Result<PathBuf> {
|
|
||||||
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
|
||||||
|
|
||||||
let dir = match app_type.to_lowercase().as_str() {
|
|
||||||
"claude" => {
|
|
||||||
// 检查是否有自定义 Claude 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
|
||||||
custom
|
|
||||||
} else {
|
|
||||||
home.join(".claude")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"codex" => {
|
|
||||||
// 检查是否有自定义 Codex 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
|
||||||
custom
|
|
||||||
} else {
|
|
||||||
home.join(".codex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"gemini" => {
|
|
||||||
// 检查是否有自定义 Gemini 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
|
||||||
custom
|
|
||||||
} else {
|
|
||||||
home.join(".gemini")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("不支持的应用类型: {app_type}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 components.json 获取市场组合
|
|
||||||
pub async fn fetch_marketplace_bundles(
|
|
||||||
&self,
|
|
||||||
conn: &rusqlite::Connection,
|
|
||||||
) -> Result<Vec<MarketplaceBundle>> {
|
|
||||||
// 获取启用的仓库
|
|
||||||
let repos = self.list_enabled_repos(conn)?;
|
|
||||||
if repos.is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut bundles = Vec::new();
|
|
||||||
|
|
||||||
for repo in repos {
|
|
||||||
// 尝试多个可能的路径
|
|
||||||
let urls = [
|
|
||||||
format!(
|
|
||||||
"https://raw.githubusercontent.com/{}/{}/{}/components.json",
|
|
||||||
repo.owner, repo.name, repo.branch
|
|
||||||
),
|
|
||||||
format!(
|
|
||||||
"https://raw.githubusercontent.com/{}/{}/{}/docs/components.json",
|
|
||||||
repo.owner, repo.name, repo.branch
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for url in urls {
|
|
||||||
match self.http_client.get(&url).send().await {
|
|
||||||
Ok(resp) if resp.status().is_success() => {
|
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
|
||||||
// 解析 marketplace.plugins(完整插件包)
|
|
||||||
if let Some(marketplace) = json.get("marketplace") {
|
|
||||||
if let Some(plugins) = marketplace.get("plugins") {
|
|
||||||
if let Some(arr) = plugins.as_array() {
|
|
||||||
for plugin in arr {
|
|
||||||
let name = plugin
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let description = plugin
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// 提取各类型组件路径
|
|
||||||
let mut components = Vec::new();
|
|
||||||
let component_types = [
|
|
||||||
"agents", "commands", "mcps", "settings", "hooks",
|
|
||||||
"skills",
|
|
||||||
];
|
|
||||||
|
|
||||||
for comp_type in component_types {
|
|
||||||
if let Some(paths) =
|
|
||||||
plugin.get(comp_type).and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
// 单数形式的类型名
|
|
||||||
let singular_type = match comp_type {
|
|
||||||
"agents" => "agent",
|
|
||||||
"commands" => "command",
|
|
||||||
"mcps" => "mcp",
|
|
||||||
"settings" => "setting",
|
|
||||||
"hooks" => "hook",
|
|
||||||
"skills" => "skill",
|
|
||||||
_ => comp_type,
|
|
||||||
};
|
|
||||||
|
|
||||||
for path_val in paths {
|
|
||||||
if let Some(path) = path_val.as_str() {
|
|
||||||
// 从路径提取组件名(文件名不含扩展名)
|
|
||||||
let comp_name =
|
|
||||||
std::path::Path::new(path)
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
components.push(
|
|
||||||
MarketplaceBundleItem {
|
|
||||||
name: comp_name,
|
|
||||||
path: path.to_string(),
|
|
||||||
component_type: singular_type
|
|
||||||
.to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !components.is_empty() {
|
|
||||||
bundles.push(MarketplaceBundle {
|
|
||||||
id: format!("{}-plugin-{}", repo.name, name),
|
|
||||||
name: name.to_string(),
|
|
||||||
description: description.to_string(),
|
|
||||||
category: "plugin".to_string(),
|
|
||||||
components,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break; // 成功获取后跳出 URL 循环
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(bundles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TemplateService {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new().expect("创建 TemplateService 失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
|
|
||||||
use super::{TemplateRepo, TemplateService};
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl TemplateService {
|
|
||||||
/// 列出所有模板仓库
|
|
||||||
pub fn list_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.context("准备查询模板仓库语句失败")?;
|
|
||||||
|
|
||||||
let repos = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get::<_, i64>(4)? != 0,
|
|
||||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
|
||||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.context("查询模板仓库失败")?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.context("收集模板仓库结果失败")?;
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加模板仓库
|
|
||||||
pub fn add_repo(&self, conn: &Connection, repo: TemplateRepo) -> Result<i64> {
|
|
||||||
// 检查是否已存在
|
|
||||||
let existing: Option<i64> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM template_repos WHERE owner = ?1 AND name = ?2",
|
|
||||||
params![&repo.owner, &repo.name],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
if let Some(id) = existing {
|
|
||||||
// 更新已存在的仓库
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos
|
|
||||||
SET branch = ?1, enabled = ?2, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?3",
|
|
||||||
params![&repo.branch, repo.enabled as i64, id],
|
|
||||||
)
|
|
||||||
.context("更新模板仓库失败")?;
|
|
||||||
Ok(id)
|
|
||||||
} else {
|
|
||||||
// 插入新仓库
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_repos (owner, name, branch, enabled)
|
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![&repo.owner, &repo.name, &repo.branch, repo.enabled as i64],
|
|
||||||
)
|
|
||||||
.context("插入模板仓库失败")?;
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模板仓库
|
|
||||||
pub fn remove_repo(&self, conn: &Connection, id: i64) -> Result<()> {
|
|
||||||
let rows = conn
|
|
||||||
.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
|
||||||
.context("删除模板仓库失败")?;
|
|
||||||
|
|
||||||
if rows == 0 {
|
|
||||||
anyhow::bail!("模板仓库不存在: id={id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换仓库启用状态
|
|
||||||
pub fn toggle_repo_enabled(&self, conn: &Connection, id: i64) -> Result<bool> {
|
|
||||||
// 获取当前状态
|
|
||||||
let enabled: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT enabled FROM template_repos WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.context("查询仓库状态失败")?;
|
|
||||||
|
|
||||||
let new_enabled = enabled == 0;
|
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos
|
|
||||||
SET enabled = ?1, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?2",
|
|
||||||
params![new_enabled as i64, id],
|
|
||||||
)
|
|
||||||
.context("更新仓库状态失败")?;
|
|
||||||
|
|
||||||
Ok(new_enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个仓库
|
|
||||||
pub fn get_repo(&self, conn: &Connection, id: i64) -> Result<TemplateRepo> {
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get::<_, i64>(4)? != 0,
|
|
||||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
|
||||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.context(format!("查询模板仓库失败: id={id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取启用的仓库列表
|
|
||||||
pub fn list_enabled_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
WHERE enabled = 1
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.context("准备查询启用仓库语句失败")?;
|
|
||||||
|
|
||||||
let repos = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get::<_, i64>(4)? != 0,
|
|
||||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
|
||||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.context("查询启用仓库失败")?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.context("收集启用仓库结果失败")?;
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use rusqlite::Connection;
|
|
||||||
|
|
||||||
fn setup_db() -> Connection {
|
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_and_list_repos() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let service = TemplateService::new().unwrap();
|
|
||||||
|
|
||||||
// 添加仓库
|
|
||||||
let repo = TemplateRepo::new(
|
|
||||||
"yovinchen".to_string(),
|
|
||||||
"claude-code-templates".to_string(),
|
|
||||||
"main".to_string(),
|
|
||||||
);
|
|
||||||
let id = service.add_repo(&conn, repo).unwrap();
|
|
||||||
assert!(id > 0);
|
|
||||||
|
|
||||||
// 列出仓库
|
|
||||||
let repos = service.list_repos(&conn).unwrap();
|
|
||||||
assert_eq!(repos.len(), 1);
|
|
||||||
assert_eq!(repos[0].owner, "yovinchen");
|
|
||||||
assert_eq!(repos[0].name, "claude-code-templates");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_toggle_repo_enabled() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let service = TemplateService::new().unwrap();
|
|
||||||
|
|
||||||
// 添加仓库
|
|
||||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
|
||||||
let id = service.add_repo(&conn, repo).unwrap();
|
|
||||||
|
|
||||||
// 切换状态
|
|
||||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
|
||||||
assert!(!enabled);
|
|
||||||
|
|
||||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
|
||||||
assert!(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_remove_repo() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let service = TemplateService::new().unwrap();
|
|
||||||
|
|
||||||
// 添加仓库
|
|
||||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
|
||||||
let id = service.add_repo(&conn, repo).unwrap();
|
|
||||||
|
|
||||||
// 删除仓库
|
|
||||||
service.remove_repo(&conn, id).unwrap();
|
|
||||||
|
|
||||||
// 验证已删除
|
|
||||||
let repos = service.list_repos(&conn).unwrap();
|
|
||||||
assert_eq!(repos.len(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -94,6 +94,9 @@ pub struct RequestLogDetail {
|
|||||||
pub provider_name: Option<String>,
|
pub provider_name: Option<String>,
|
||||||
pub app_type: String,
|
pub app_type: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub request_model: Option<String>,
|
||||||
|
pub cost_multiplier: String,
|
||||||
pub input_tokens: u32,
|
pub input_tokens: u32,
|
||||||
pub output_tokens: u32,
|
pub output_tokens: u32,
|
||||||
pub cache_read_tokens: u32,
|
pub cache_read_tokens: u32,
|
||||||
@@ -140,7 +143,7 @@ impl Database {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT
|
"SELECT
|
||||||
COUNT(*) as total_requests,
|
COUNT(*) as total_requests,
|
||||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||||
@@ -218,7 +221,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sql = "
|
let sql = "
|
||||||
SELECT
|
SELECT
|
||||||
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
|
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||||
COUNT(*) as request_count,
|
COUNT(*) as request_count,
|
||||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||||
@@ -269,7 +272,7 @@ impl Database {
|
|||||||
.single()
|
.single()
|
||||||
.unwrap_or_else(Local::now);
|
.unwrap_or_else(Local::now);
|
||||||
|
|
||||||
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
|
let date = bucket_start.to_rfc3339();
|
||||||
|
|
||||||
if let Some(mut stat) = map.remove(&i) {
|
if let Some(mut stat) = map.remove(&i) {
|
||||||
stat.date = date;
|
stat.date = date;
|
||||||
@@ -295,7 +298,7 @@ impl Database {
|
|||||||
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
|
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
let sql = "SELECT
|
let sql = "SELECT
|
||||||
l.provider_id,
|
l.provider_id,
|
||||||
p.name as provider_name,
|
p.name as provider_name,
|
||||||
COUNT(*) as request_count,
|
COUNT(*) as request_count,
|
||||||
@@ -343,7 +346,7 @@ impl Database {
|
|||||||
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
|
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
let sql = "SELECT
|
let sql = "SELECT
|
||||||
model,
|
model,
|
||||||
COUNT(*) as request_count,
|
COUNT(*) as request_count,
|
||||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||||
@@ -424,7 +427,7 @@ impl Database {
|
|||||||
|
|
||||||
// 获取总数
|
// 获取总数
|
||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*) FROM proxy_request_logs l
|
"SELECT COUNT(*) FROM proxy_request_logs l
|
||||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||||
{where_clause}"
|
{where_clause}"
|
||||||
);
|
);
|
||||||
@@ -440,6 +443,7 @@ impl Database {
|
|||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
||||||
|
l.request_model, l.cost_multiplier,
|
||||||
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
|
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
|
||||||
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
|
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
|
||||||
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
|
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
|
||||||
@@ -460,22 +464,26 @@ impl Database {
|
|||||||
provider_name: row.get(2)?,
|
provider_name: row.get(2)?,
|
||||||
app_type: row.get(3)?,
|
app_type: row.get(3)?,
|
||||||
model: row.get(4)?,
|
model: row.get(4)?,
|
||||||
input_tokens: row.get::<_, i64>(5)? as u32,
|
request_model: row.get(5)?,
|
||||||
output_tokens: row.get::<_, i64>(6)? as u32,
|
cost_multiplier: row
|
||||||
cache_read_tokens: row.get::<_, i64>(7)? as u32,
|
.get::<_, Option<String>>(6)?
|
||||||
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
|
.unwrap_or_else(|| "1".to_string()),
|
||||||
input_cost_usd: row.get(9)?,
|
input_tokens: row.get::<_, i64>(7)? as u32,
|
||||||
output_cost_usd: row.get(10)?,
|
output_tokens: row.get::<_, i64>(8)? as u32,
|
||||||
cache_read_cost_usd: row.get(11)?,
|
cache_read_tokens: row.get::<_, i64>(9)? as u32,
|
||||||
cache_creation_cost_usd: row.get(12)?,
|
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
|
||||||
total_cost_usd: row.get(13)?,
|
input_cost_usd: row.get(11)?,
|
||||||
is_streaming: row.get::<_, i64>(14)? != 0,
|
output_cost_usd: row.get(12)?,
|
||||||
latency_ms: row.get::<_, i64>(15)? as u64,
|
cache_read_cost_usd: row.get(13)?,
|
||||||
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
|
cache_creation_cost_usd: row.get(14)?,
|
||||||
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
|
total_cost_usd: row.get(15)?,
|
||||||
status_code: row.get::<_, i64>(18)? as u16,
|
is_streaming: row.get::<_, i64>(16)? != 0,
|
||||||
error_message: row.get(19)?,
|
latency_ms: row.get::<_, i64>(17)? as u64,
|
||||||
created_at: row.get(20)?,
|
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
|
||||||
|
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
|
||||||
|
status_code: row.get::<_, i64>(20)? as u16,
|
||||||
|
error_message: row.get(21)?,
|
||||||
|
created_at: row.get(22)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -511,6 +519,7 @@ impl Database {
|
|||||||
|
|
||||||
let result = conn.query_row(
|
let result = conn.query_row(
|
||||||
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
||||||
|
l.request_model, l.cost_multiplier,
|
||||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||||
is_streaming, latency_ms, first_token_ms, duration_ms,
|
is_streaming, latency_ms, first_token_ms, duration_ms,
|
||||||
@@ -526,22 +535,24 @@ impl Database {
|
|||||||
provider_name: row.get(2)?,
|
provider_name: row.get(2)?,
|
||||||
app_type: row.get(3)?,
|
app_type: row.get(3)?,
|
||||||
model: row.get(4)?,
|
model: row.get(4)?,
|
||||||
input_tokens: row.get::<_, i64>(5)? as u32,
|
request_model: row.get(5)?,
|
||||||
output_tokens: row.get::<_, i64>(6)? as u32,
|
cost_multiplier: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "1".to_string()),
|
||||||
cache_read_tokens: row.get::<_, i64>(7)? as u32,
|
input_tokens: row.get::<_, i64>(7)? as u32,
|
||||||
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
|
output_tokens: row.get::<_, i64>(8)? as u32,
|
||||||
input_cost_usd: row.get(9)?,
|
cache_read_tokens: row.get::<_, i64>(9)? as u32,
|
||||||
output_cost_usd: row.get(10)?,
|
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
|
||||||
cache_read_cost_usd: row.get(11)?,
|
input_cost_usd: row.get(11)?,
|
||||||
cache_creation_cost_usd: row.get(12)?,
|
output_cost_usd: row.get(12)?,
|
||||||
total_cost_usd: row.get(13)?,
|
cache_read_cost_usd: row.get(13)?,
|
||||||
is_streaming: row.get::<_, i64>(14)? != 0,
|
cache_creation_cost_usd: row.get(14)?,
|
||||||
latency_ms: row.get::<_, i64>(15)? as u64,
|
total_cost_usd: row.get(15)?,
|
||||||
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
|
is_streaming: row.get::<_, i64>(16)? != 0,
|
||||||
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
|
latency_ms: row.get::<_, i64>(17)? as u64,
|
||||||
status_code: row.get::<_, i64>(18)? as u16,
|
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
|
||||||
error_message: row.get(19)?,
|
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
|
||||||
created_at: row.get(20)?,
|
status_code: row.get::<_, i64>(20)? as u16,
|
||||||
|
error_message: row.get(21)?,
|
||||||
|
created_at: row.get(22)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -691,21 +702,26 @@ impl Database {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let million = rust_decimal::Decimal::from(1_000_000u64);
|
let million = rust_decimal::Decimal::from(1_000_000u64);
|
||||||
let input_cost = rust_decimal::Decimal::from(log.input_tokens as u64) * pricing.input
|
|
||||||
/ million
|
// 与 CostCalculator::calculate 保持一致的计算逻辑:
|
||||||
* multiplier;
|
// 1. input_cost 需要扣除 cache_read_tokens(避免缓存部分被重复计费)
|
||||||
let output_cost = rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output
|
// 2. 各项成本是基础成本(不含倍率)
|
||||||
/ million
|
// 3. 倍率只作用于最终总价
|
||||||
* multiplier;
|
let billable_input_tokens =
|
||||||
|
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64);
|
||||||
|
let input_cost =
|
||||||
|
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
|
||||||
|
let output_cost =
|
||||||
|
rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output / million;
|
||||||
let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
|
let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
|
||||||
* pricing.cache_read
|
* pricing.cache_read
|
||||||
/ million
|
/ million;
|
||||||
* multiplier;
|
|
||||||
let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)
|
let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)
|
||||||
* pricing.cache_creation
|
* pricing.cache_creation
|
||||||
/ million
|
/ million;
|
||||||
* multiplier;
|
// 总成本 = 基础成本之和 × 倍率
|
||||||
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||||||
|
let total_cost = base_total * multiplier;
|
||||||
|
|
||||||
log.input_cost_usd = format!("{input_cost:.6}");
|
log.input_cost_usd = format!("{input_cost:.6}");
|
||||||
log.output_cost_usd = format!("{output_cost:.6}");
|
log.output_cost_usd = format!("{output_cost:.6}");
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
pub mod providers;
|
||||||
|
pub mod terminal;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use providers::{claude, codex};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionMeta {
|
||||||
|
pub provider_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub summary: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub project_dir: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_active_at: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resume_command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ts: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
sessions.extend(codex::scan_sessions());
|
||||||
|
sessions.extend(claude::scan_sessions());
|
||||||
|
|
||||||
|
sessions.sort_by(|a, b| {
|
||||||
|
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||||
|
let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0);
|
||||||
|
b_ts.cmp(&a_ts)
|
||||||
|
});
|
||||||
|
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<SessionMessage>, String> {
|
||||||
|
let path = Path::new(source_path);
|
||||||
|
match provider_id {
|
||||||
|
"codex" => codex::load_messages(path),
|
||||||
|
"claude" => claude::load_messages(path),
|
||||||
|
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::config::get_claude_config_dir;
|
||||||
|
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||||
|
|
||||||
|
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||||
|
|
||||||
|
const PROVIDER_ID: &str = "claude";
|
||||||
|
|
||||||
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
|
let root = get_claude_config_dir().join("projects");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_jsonl_files(&root, &mut files);
|
||||||
|
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
for path in files {
|
||||||
|
if let Some(meta) = parse_session(&path) {
|
||||||
|
sessions.push(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||||
|
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: Value = match serde_json::from_str(&line) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = match value.get("message") {
|
||||||
|
Some(message) => message,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let role = message
|
||||||
|
.get("role")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let content = message.get("content").map(extract_text).unwrap_or_default();
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
|
|
||||||
|
messages.push(SessionMessage { role, content, ts });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||||
|
if is_agent_session(path) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = File::open(path).ok()?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut session_id: Option<String> = None;
|
||||||
|
let mut project_dir: Option<String> = None;
|
||||||
|
let mut created_at: Option<i64> = None;
|
||||||
|
let mut last_active_at: Option<i64> = None;
|
||||||
|
let mut summary: Option<String> = None;
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: Value = match serde_json::from_str(&line) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if session_id.is_none() {
|
||||||
|
session_id = value
|
||||||
|
.get("sessionId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if project_dir.is_none() {
|
||||||
|
project_dir = value
|
||||||
|
.get("cwd")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||||
|
if created_at.is_none() {
|
||||||
|
created_at = Some(ts);
|
||||||
|
}
|
||||||
|
last_active_at = Some(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = match value.get("message") {
|
||||||
|
Some(message) => message,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
summary = Some(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||||
|
let session_id = session_id?;
|
||||||
|
|
||||||
|
let title = project_dir
|
||||||
|
.as_deref()
|
||||||
|
.and_then(path_basename)
|
||||||
|
.map(|value| value.to_string());
|
||||||
|
|
||||||
|
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||||
|
|
||||||
|
Some(SessionMeta {
|
||||||
|
provider_id: PROVIDER_ID.to_string(),
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
project_dir,
|
||||||
|
created_at,
|
||||||
|
last_active_at,
|
||||||
|
source_path: Some(path.to_string_lossy().to_string()),
|
||||||
|
resume_command: Some(format!("claude --resume {session_id}")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_agent_session(path: &Path) -> bool {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(|name| name.starts_with("agent-"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||||
|
path.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.map(|stem| stem.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||||
|
if !root.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(root) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_jsonl_files(&path, files);
|
||||||
|
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::codex_config::get_codex_config_dir;
|
||||||
|
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||||
|
|
||||||
|
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||||
|
|
||||||
|
const PROVIDER_ID: &str = "codex";
|
||||||
|
|
||||||
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
|
let root = get_codex_config_dir().join("sessions");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_jsonl_files(&root, &mut files);
|
||||||
|
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
for path in files {
|
||||||
|
if let Some(meta) = parse_session(&path) {
|
||||||
|
sessions.push(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||||
|
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: Value = match serde_json::from_str(&line) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if value.get("type").and_then(Value::as_str) != Some("response_item") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = match value.get("payload") {
|
||||||
|
Some(payload) => payload,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let role = payload
|
||||||
|
.get("role")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let content = payload.get("content").map(extract_text).unwrap_or_default();
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
|
|
||||||
|
messages.push(SessionMessage { role, content, ts });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||||
|
let file = File::open(path).ok()?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut session_id: Option<String> = None;
|
||||||
|
let mut project_dir: Option<String> = None;
|
||||||
|
let mut created_at: Option<i64> = None;
|
||||||
|
let mut last_active_at: Option<i64> = None;
|
||||||
|
let mut summary: Option<String> = None;
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: Value = match serde_json::from_str(&line) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||||
|
if created_at.is_none() {
|
||||||
|
created_at = Some(ts);
|
||||||
|
}
|
||||||
|
last_active_at = Some(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
|
||||||
|
if let Some(payload) = value.get("payload") {
|
||||||
|
if session_id.is_none() {
|
||||||
|
session_id = payload
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
if project_dir.is_none() {
|
||||||
|
project_dir = payload
|
||||||
|
.get("cwd")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
if let Some(ts) = payload.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||||
|
created_at.get_or_insert(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.get("type").and_then(Value::as_str) != Some("response_item") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = match value.get("payload") {
|
||||||
|
Some(payload) => payload,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
summary = Some(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||||
|
let session_id = session_id?;
|
||||||
|
|
||||||
|
let title = project_dir
|
||||||
|
.as_deref()
|
||||||
|
.and_then(path_basename)
|
||||||
|
.map(|value| value.to_string());
|
||||||
|
|
||||||
|
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||||
|
|
||||||
|
Some(SessionMeta {
|
||||||
|
provider_id: PROVIDER_ID.to_string(),
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
project_dir,
|
||||||
|
created_at,
|
||||||
|
last_active_at,
|
||||||
|
source_path: Some(path.to_string_lossy().to_string()),
|
||||||
|
resume_command: Some(format!("codex resume {session_id}")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||||
|
let file_name = path.file_name()?.to_string_lossy();
|
||||||
|
let re =
|
||||||
|
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||||
|
.ok()?;
|
||||||
|
re.find(&file_name).map(|mat| mat.as_str().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||||
|
if !root.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(root) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_jsonl_files(&path, files);
|
||||||
|
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod claude;
|
||||||
|
pub mod codex;
|
||||||
|
mod utils;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use chrono::{DateTime, FixedOffset};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
|
||||||
|
let raw = value.as_str()?;
|
||||||
|
DateTime::parse_from_rfc3339(raw)
|
||||||
|
.ok()
|
||||||
|
.map(|dt: DateTime<FixedOffset>| dt.timestamp_millis())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_text(content: &Value) -> String {
|
||||||
|
match content {
|
||||||
|
Value::String(text) => text.to_string(),
|
||||||
|
Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.filter_map(extract_text_from_item)
|
||||||
|
.filter(|text| !text.trim().is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
Value::Object(map) => map
|
||||||
|
.get("text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_text_from_item(item: &Value) -> Option<String> {
|
||||||
|
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
|
||||||
|
return Some(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = item.get("input_text").and_then(|v| v.as_str()) {
|
||||||
|
return Some(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = item.get("output_text").and_then(|v| v.as_str()) {
|
||||||
|
return Some(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(content) = item.get("content") {
|
||||||
|
let text = extract_text(content);
|
||||||
|
if !text.is_empty() {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn truncate_summary(text: &str, max_chars: usize) -> String {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
if trimmed.chars().count() <= max_chars {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = trimmed.chars().take(max_chars).collect::<String>();
|
||||||
|
result.push_str("...");
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path_basename(value: &str) -> Option<String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let normalized = trimmed.trim_end_matches(['/', '\\']);
|
||||||
|
let last = normalized
|
||||||
|
.split(['/', '\\'])
|
||||||
|
.next_back()
|
||||||
|
.filter(|segment| !segment.is_empty())?;
|
||||||
|
Some(last.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub fn launch_terminal(
|
||||||
|
target: &str,
|
||||||
|
command: &str,
|
||||||
|
cwd: Option<&str>,
|
||||||
|
custom_config: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if command.trim().is_empty() {
|
||||||
|
return Err("Resume command is empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg!(target_os = "macos") {
|
||||||
|
return Err("Terminal resume is only supported on macOS".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
match target {
|
||||||
|
"terminal" => launch_macos_terminal(command, cwd),
|
||||||
|
"iTerm" | "iterm" => launch_iterm(command, cwd),
|
||||||
|
"ghostty" => launch_ghostty(command, cwd),
|
||||||
|
"kitty" => launch_kitty(command, cwd),
|
||||||
|
"wezterm" => launch_wezterm(command, cwd),
|
||||||
|
"alacritty" => launch_alacritty(command, cwd),
|
||||||
|
"custom" => launch_custom(command, cwd, custom_config),
|
||||||
|
_ => Err(format!("Unsupported terminal target: {target}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||||
|
let full_command = build_shell_command(command, cwd);
|
||||||
|
let escaped = escape_osascript(&full_command);
|
||||||
|
let script = format!(
|
||||||
|
r#"tell application "Terminal"
|
||||||
|
activate
|
||||||
|
do script "{escaped}"
|
||||||
|
end tell"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = Command::new("osascript")
|
||||||
|
.arg("-e")
|
||||||
|
.arg(script)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to launch Terminal: {e}"))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Terminal command execution failed".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||||
|
let full_command = build_shell_command(command, cwd);
|
||||||
|
let escaped = escape_osascript(&full_command);
|
||||||
|
// iTerm2 AppleScript to create a new window and execute command
|
||||||
|
let script = format!(
|
||||||
|
r#"tell application "iTerm"
|
||||||
|
activate
|
||||||
|
create window with default profile
|
||||||
|
tell current session of current window
|
||||||
|
write text "{escaped}"
|
||||||
|
end tell
|
||||||
|
end tell"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = Command::new("osascript")
|
||||||
|
.arg("-e")
|
||||||
|
.arg(script)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to launch iTerm: {e}"))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("iTerm command execution failed".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||||
|
// Ghostty usage: open -na Ghostty --args +work-dir=... -e shell -c command
|
||||||
|
|
||||||
|
// Using `open` to launch.
|
||||||
|
let mut args = vec!["-na", "Ghostty", "--args"];
|
||||||
|
|
||||||
|
// Ghostty uses --working-directory for working directory (or +work-dir, but --working-directory is standard in newer versions/compat)
|
||||||
|
// Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones.
|
||||||
|
// Documentation says --working-directory is supported in CLI.
|
||||||
|
let work_dir_arg = if let Some(dir) = cwd {
|
||||||
|
format!("--working-directory={dir}")
|
||||||
|
} else {
|
||||||
|
"".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !work_dir_arg.is_empty() {
|
||||||
|
args.push(&work_dir_arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command execution
|
||||||
|
args.push("-e");
|
||||||
|
|
||||||
|
// We pass the command and its arguments separately.
|
||||||
|
// The previous issue was passing the entire "cmd args" string as a single argument to -e,
|
||||||
|
// which led Ghostty to look for a binary named "cmd args".
|
||||||
|
// Splitting by whitespace allows Ghostty to see ["cmd", "args"].
|
||||||
|
// Note: This assumes simple commands without quoted arguments containing spaces.
|
||||||
|
let full_command = build_shell_command(command, None);
|
||||||
|
for part in full_command.split_whitespace() {
|
||||||
|
args.push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = Command::new("open")
|
||||||
|
.args(&args)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to launch Ghostty: {e}"))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Failed to launch Ghostty. Make sure it is installed.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||||
|
let full_command = build_shell_command(command, cwd);
|
||||||
|
|
||||||
|
// 获取用户默认 shell
|
||||||
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||||
|
|
||||||
|
let status = Command::new("open")
|
||||||
|
.arg("-na")
|
||||||
|
.arg("kitty")
|
||||||
|
.arg("--args")
|
||||||
|
.arg("-e")
|
||||||
|
.arg(&shell)
|
||||||
|
.arg("-l")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&full_command)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to launch Kitty: {e}"))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Failed to launch Kitty. Make sure it is installed.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||||
|
// wezterm start --cwd ... -- command
|
||||||
|
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
|
||||||
|
|
||||||
|
let full_command = build_shell_command(command, None);
|
||||||
|
|
||||||
|
let mut args = vec!["-na", "WezTerm", "--args", "start"];
|
||||||
|
|
||||||
|
if let Some(dir) = cwd {
|
||||||
|
args.push("--cwd");
|
||||||
|
args.push(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke shell to run the command string (to handle pipes, etc)
|
||||||
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||||
|
args.push("--");
|
||||||
|
args.push(&shell);
|
||||||
|
args.push("-c");
|
||||||
|
args.push(&full_command);
|
||||||
|
|
||||||
|
let status = Command::new("open")
|
||||||
|
.args(&args)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Failed to launch WezTerm.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||||
|
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
|
||||||
|
let full_command = build_shell_command(command, None);
|
||||||
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||||
|
|
||||||
|
let mut args = vec!["-na", "Alacritty", "--args"];
|
||||||
|
|
||||||
|
if let Some(dir) = cwd {
|
||||||
|
args.push("--working-directory");
|
||||||
|
args.push(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push("-e");
|
||||||
|
args.push(&shell);
|
||||||
|
args.push("-c");
|
||||||
|
args.push(&full_command);
|
||||||
|
|
||||||
|
let status = Command::new("open")
|
||||||
|
.args(&args)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to launch Alacritty: {e}"))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Failed to launch Alacritty.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_custom(
|
||||||
|
command: &str,
|
||||||
|
cwd: Option<&str>,
|
||||||
|
custom_config: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let template = custom_config.ok_or("No custom terminal config provided")?;
|
||||||
|
|
||||||
|
if template.trim().is_empty() {
|
||||||
|
return Err("Custom terminal command template is empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cmd_str = command;
|
||||||
|
let dir_str = cwd.unwrap_or(".");
|
||||||
|
|
||||||
|
let final_cmd_line = template
|
||||||
|
.replace("{command}", cmd_str)
|
||||||
|
.replace("{cwd}", dir_str);
|
||||||
|
|
||||||
|
// Execute via sh -c
|
||||||
|
let status = Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&final_cmd_line)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to execute custom terminal launcher: {e}"))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Custom terminal execution returned error code".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
|
||||||
|
match cwd {
|
||||||
|
Some(dir) if !dir.trim().is_empty() => {
|
||||||
|
format!("cd {} && {}", shell_escape(dir), command)
|
||||||
|
}
|
||||||
|
_ => command.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_escape(value: &str) -> String {
|
||||||
|
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
|
format!("\"{escaped}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_osascript(value: &str) -> String {
|
||||||
|
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user