mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a353518e9b | |||
| 6388d24c9a | |||
| 5502c74a79 | |||
| fa7c9514cc | |||
| a20ff157bf | |||
| da27e22f77 | |||
| 5490a540f1 | |||
| d82027f107 | |||
| ded9980fbf | |||
| 3d91c381d9 | |||
| b8538a6996 | |||
| 87b80c66b2 | |||
| 14fa749ca9 | |||
| 95bc0e38df | |||
| 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 |
@@ -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
|
||||||
|
|||||||
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Windows Home Dir Regression**: Prevent providers/settings “disappearing” after upgrading from v3.10.2 → v3.10.3 when `HOME` differs from the real user profile directory; restore default path resolution and auto-detect the v3.10.3 legacy database location.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.10.3] - 2026-01-30
|
||||||
|
|
||||||
|
### Feature Release
|
||||||
|
|
||||||
|
This release introduces a generic API format selector, pricing configuration enhancements, and multiple UX improvements.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **API Key Link for OpenCode**: API key link support for OpenCode provider form, enabling quick access to provider key management pages
|
||||||
|
- **AICodeMirror Partner Preset**: Added AICodeMirror partner preset for all apps (Claude, Codex, Gemini, OpenCode)
|
||||||
|
- **API Format Selector**: Generic API format chooser for Claude providers, replacing the OpenRouter-specific toggle. Supports Anthropic Messages (native) and OpenAI Chat Completions format
|
||||||
|
- **API Format Presets**: Allow preset providers to specify API format (anthropic or openai_chat) for third-party proxy services
|
||||||
|
- **Proxy Hint**: Display info toast when switching to OpenAI Chat format provider, reminding users to enable proxy
|
||||||
|
- **Pricing Config Enhancement**: Per-provider cost multiplier, pricing model source (request/response), request model logging, and enriched usage UI (#781)
|
||||||
|
- **Skills ZIP Install**: Install skills directly from local ZIP files with recursive scanning support
|
||||||
|
- **Preferred Terminal**: Choose preferred terminal app per platform (macOS: Terminal.app/iTerm2/Alacritty/Kitty/Ghostty; Windows: cmd/PowerShell/Windows Terminal; Linux: GNOME Terminal/Konsole/Xfce4/Alacritty/Kitty/Ghostty)
|
||||||
|
- **Silent Startup**: Option to prevent window popup on launch (#713)
|
||||||
|
- **OpenCode Environment Check**: Version detection with Go path scanning and one-click install from GitHub Releases
|
||||||
|
- **OpenCode Directory Sync**: Auto-sync all providers to live config on directory change with additive mode support
|
||||||
|
- **NVIDIA NIM Preset**: New provider preset for Claude and OpenCode with nvidia.svg icon
|
||||||
|
- **n1n.ai Preset**: New provider preset (#667)
|
||||||
|
- **Update Badge Icon**: Replace update badge dot with ArrowUpCircle icon
|
||||||
|
- **Linux ARM64**: CI build support for Linux ARM64 architecture
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **API Format Migration**: Migrate api_format from settings_config to ProviderMeta to prevent polluting ~/.claude/settings.json
|
||||||
|
- **DeepSeek max_tokens**: Remove max_tokens clamp from proxy transform layer
|
||||||
|
- **Terminal Functions**: Consolidate redundant terminal launch functions
|
||||||
|
- **Home Dir Utility**: Consolidate get_home_dir into single public function
|
||||||
|
- **Kimi/Moonshot**: Upgrade provider presets to k2.5 model
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Codex 404 & Timeout**: Fix 404 errors and connection timeout with custom base_url; improve /v1 prefix handling and system proxy detection (#760)
|
||||||
|
- **Proxy URL Building**: Fix duplicate /v1/v1 in URL; extend ?beta=true to /v1/chat/completions endpoint
|
||||||
|
- **OpenRouter Compat Mode**: Improve backward compatibility supporting number and string types
|
||||||
|
- **Gemini Visibility**: Correct Gemini default visibility to true (#818)
|
||||||
|
- **Footer Layout**: Correct footer layout in advanced settings tab
|
||||||
|
- **Claude Code Detection**: Prioritize native install path for detection
|
||||||
|
- **Tray Menu**: Simplify title labels and optimize menu separators (#796)
|
||||||
|
- **Duplicate Skills**: Prevent duplicate skill installation from different repos (#778)
|
||||||
|
- **Windows Tests**: Stabilize test environment (#644)
|
||||||
|
- **i18n**: Update apiFormatOpenAIChat label to mention proxy requirement
|
||||||
|
- **Error Display**: Use extractErrorMessage for complete error display in mutations
|
||||||
|
- **Sponsors**: Add AICodeMirror and reorder sponsor list
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [3.10.2] - 2026-01-24
|
## [3.10.2] - 2026-01-24
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+13
-2
@@ -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>
|
||||||
|
|
||||||
## スクリーンショット
|
## スクリーンショット
|
||||||
|
|||||||
+14
-2
@@ -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>
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.10.2",
|
"version": "3.10.3",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -57,10 +57,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
-1
@@ -701,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.10.1"
|
version = "3.10.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.10.2"
|
version = "3.10.3"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+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>
|
||||||
@@ -282,6 +282,25 @@ impl AppType {
|
|||||||
AppType::OpenCode => "opencode",
|
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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use crate::codex_config;
|
|||||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||||
use crate::settings;
|
use crate::settings;
|
||||||
|
|
||||||
/// 获取 Claude Code 配置状态
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||||
Ok(config::get_claude_config_status())
|
Ok(config::get_claude_config_status())
|
||||||
@@ -63,13 +62,11 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Claude Code 配置文件路径
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||||
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前生效的配置目录
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||||
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
@@ -82,7 +79,6 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
|||||||
Ok(dir.to_string_lossy().to_string())
|
Ok(dir.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开配置文件夹
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
|
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
|
||||||
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
@@ -104,7 +100,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 弹出系统目录选择器并返回用户选择的路径
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn pick_directory(
|
pub async fn pick_directory(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -136,14 +131,12 @@ pub async fn pick_directory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取应用配置文件路径
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_app_config_path() -> Result<String, String> {
|
pub async fn get_app_config_path() -> Result<String, String> {
|
||||||
let config_path = config::get_app_config_path();
|
let config_path = config::get_app_config_path();
|
||||||
Ok(config_path.to_string_lossy().to_string())
|
Ok(config_path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开应用配置文件夹
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
||||||
let config_dir = config::get_app_config_dir();
|
let config_dir = config::get_app_config_dir();
|
||||||
@@ -160,7 +153,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_claude_common_config_snippet(
|
pub async fn get_claude_common_config_snippet(
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
@@ -171,13 +163,11 @@ pub async fn get_claude_common_config_snippet(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_claude_common_config_snippet(
|
pub async fn set_claude_common_config_snippet(
|
||||||
snippet: String,
|
snippet: String,
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// 验证是否为有效的 JSON(如果不为空)
|
|
||||||
if !snippet.trim().is_empty() {
|
if !snippet.trim().is_empty() {
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||||
}
|
}
|
||||||
@@ -195,7 +185,6 @@ pub async fn set_claude_common_config_snippet(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取通用配置片段(统一接口)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_common_config_snippet(
|
pub async fn get_common_config_snippet(
|
||||||
app_type: String,
|
app_type: String,
|
||||||
@@ -207,25 +196,19 @@ pub async fn get_common_config_snippet(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置通用配置片段(统一接口)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_common_config_snippet(
|
pub async fn set_common_config_snippet(
|
||||||
app_type: String,
|
app_type: String,
|
||||||
snippet: String,
|
snippet: String,
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// 验证格式(根据应用类型)
|
|
||||||
if !snippet.trim().is_empty() {
|
if !snippet.trim().is_empty() {
|
||||||
match app_type.as_str() {
|
match app_type.as_str() {
|
||||||
"claude" | "gemini" => {
|
"claude" | "gemini" | "omo" => {
|
||||||
// 验证 JSON 格式
|
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
.map_err(invalid_json_format_error)?;
|
.map_err(invalid_json_format_error)?;
|
||||||
}
|
}
|
||||||
"codex" => {
|
"codex" => {}
|
||||||
// TOML 格式暂不验证(或可使用 toml crate)
|
|
||||||
// 注意:TOML 验证较为复杂,暂时跳过
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,14 +223,20 @@ pub async fn set_common_config_snippet(
|
|||||||
.db
|
.db
|
||||||
.set_config_snippet(&app_type, value)
|
.set_config_snippet(&app_type, value)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if app_type == "omo"
|
||||||
|
&& state
|
||||||
|
.db
|
||||||
|
.get_current_omo_provider("opencode")
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
crate::services::OmoService::write_config_to_file(state.inner())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提取通用配置片段
|
|
||||||
///
|
|
||||||
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
|
|
||||||
///
|
|
||||||
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn extract_common_config_snippet(
|
pub async fn extract_common_config_snippet(
|
||||||
appType: String,
|
appType: String,
|
||||||
|
|||||||
@@ -109,3 +109,17 @@ pub async fn open_file_dialog<R: tauri::Runtime>(
|
|||||||
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
Ok(result.map(|p| p.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 打开 ZIP 文件选择对话框
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_zip_file_dialog<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let dialog = app.dialog();
|
||||||
|
let result = dialog
|
||||||
|
.file()
|
||||||
|
.add_filter("ZIP", &["zip"])
|
||||||
|
.blocking_pick_file();
|
||||||
|
|
||||||
|
Ok(result.map(|p| p.to_string()))
|
||||||
|
}
|
||||||
|
|||||||
+251
-37
@@ -89,7 +89,7 @@ pub struct ToolVersion {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||||
let tools = vec!["claude", "codex", "gemini"];
|
let tools = vec!["claude", "codex", "gemini", "opencode"];
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
@@ -116,6 +116,7 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
|||||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||||
|
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,6 +149,29 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper function to fetch latest version from GitHub releases
|
||||||
|
async fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option<String> {
|
||||||
|
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
|
||||||
|
match client
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "cc-switch")
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||||
|
json.get("tag_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.strip_prefix('v').unwrap_or(s).to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 预编译的版本号正则表达式
|
/// 预编译的版本号正则表达式
|
||||||
static VERSION_RE: Lazy<Regex> =
|
static VERSION_RE: Lazy<Regex> =
|
||||||
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
|
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
|
||||||
@@ -224,7 +248,7 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
|||||||
|
|
||||||
// 防御性断言:tool 只能是预定义的值
|
// 防御性断言:tool 只能是预定义的值
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
["claude", "codex", "gemini"].contains(&tool),
|
["claude", "codex", "gemini", "opencode"].contains(&tool),
|
||||||
"unexpected tool name: {tool}"
|
"unexpected tool name: {tool}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -295,11 +319,12 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
|
|
||||||
let home = dirs::home_dir().unwrap_or_default();
|
let home = dirs::home_dir().unwrap_or_default();
|
||||||
|
|
||||||
// 常见的 npm 全局安装路径
|
// 常见的安装路径(原生安装优先)
|
||||||
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
||||||
|
home.join(".local/bin"), // Native install (official recommended)
|
||||||
home.join(".npm-global/bin"),
|
home.join(".npm-global/bin"),
|
||||||
home.join(".local/bin"),
|
|
||||||
home.join("n/bin"), // n version manager
|
home.join("n/bin"), // n version manager
|
||||||
|
home.join(".volta/bin"), // Volta package manager
|
||||||
];
|
];
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -348,6 +373,14 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 Go 路径支持 (opencode 使用 go install 安装)
|
||||||
|
if tool == "opencode" {
|
||||||
|
search_paths.push(home.join("go/bin")); // go install 默认路径
|
||||||
|
if let Ok(gopath) = std::env::var("GOPATH") {
|
||||||
|
search_paths.push(std::path::PathBuf::from(gopath).join("bin"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 在每个路径中查找工具
|
// 在每个路径中查找工具
|
||||||
for path in &search_paths {
|
for path in &search_paths {
|
||||||
let tool_path = if cfg!(target_os = "windows") {
|
let tool_path = if cfg!(target_os = "windows") {
|
||||||
@@ -405,6 +438,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
|||||||
"claude" => crate::settings::get_claude_override_dir(),
|
"claude" => crate::settings::get_claude_override_dir(),
|
||||||
"codex" => crate::settings::get_codex_override_dir(),
|
"codex" => crate::settings::get_codex_override_dir(),
|
||||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||||
|
"opencode" => crate::settings::get_opencode_override_dir(),
|
||||||
_ => None,
|
_ => None,
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
@@ -581,18 +615,19 @@ 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}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// macOS: 使用 Terminal.app 启动
|
/// macOS: 根据用户首选终端启动
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::process::Command;
|
|
||||||
|
let preferred = crate::settings::get_preferred_terminal();
|
||||||
|
let terminal = preferred.as_deref().unwrap_or("terminal");
|
||||||
|
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||||
|
|
||||||
let config_path = config_file.to_string_lossy();
|
let config_path = config_file.to_string_lossy();
|
||||||
|
|
||||||
// Write the shell script to a temp file (no escaping needed!)
|
// Write the shell script to a temp file
|
||||||
let script_content = format!(
|
let script_content = format!(
|
||||||
r#"#!/bin/bash
|
r#"#!/bin/bash
|
||||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||||
@@ -611,7 +646,35 @@ exec bash --norc --noprofile
|
|||||||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||||
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||||
|
|
||||||
// Simple AppleScript - just execute the script file
|
// 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!(
|
let applescript = format!(
|
||||||
r#"tell application "Terminal"
|
r#"tell application "Terminal"
|
||||||
activate
|
activate
|
||||||
@@ -627,12 +690,9 @@ end tell"#,
|
|||||||
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
// Clean up on failure
|
|
||||||
let _ = std::fs::remove_file(&script_file);
|
|
||||||
let _ = std::fs::remove_file(config_file);
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"AppleScript 执行失败 (exit code: {:?}): {}",
|
"Terminal.app 执行失败 (exit code: {:?}): {}",
|
||||||
output.status.code(),
|
output.status.code(),
|
||||||
stderr
|
stderr
|
||||||
));
|
));
|
||||||
@@ -641,13 +701,86 @@ end tell"#,
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linux: 尝试使用常见终端启动
|
/// macOS: iTerm2
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let applescript = format!(
|
||||||
|
r#"tell application "iTerm"
|
||||||
|
activate
|
||||||
|
tell current window
|
||||||
|
create tab with default profile
|
||||||
|
tell current session
|
||||||
|
write text "bash '{}'"
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
end tell"#,
|
||||||
|
script_file.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = Command::new("osascript")
|
||||||
|
.arg("-e")
|
||||||
|
.arg(&applescript)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!(
|
||||||
|
"iTerm2 执行失败 (exit code: {:?}): {}",
|
||||||
|
output.status.code(),
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// macOS: 使用 open -a 启动支持 --args 参数的终端(Alacritty/Kitty/Ghostty)
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn launch_macos_open_app(
|
||||||
|
app_name: &str,
|
||||||
|
script_file: &std::path::Path,
|
||||||
|
use_e_flag: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let mut cmd = Command::new("open");
|
||||||
|
cmd.arg("-a").arg(app_name).arg("--args");
|
||||||
|
|
||||||
|
if use_e_flag {
|
||||||
|
cmd.arg("-e");
|
||||||
|
}
|
||||||
|
cmd.arg("bash").arg(script_file);
|
||||||
|
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!(
|
||||||
|
"{} 启动失败 (exit code: {:?}): {}",
|
||||||
|
app_name,
|
||||||
|
output.status.code(),
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux: 根据用户首选终端启动
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let terminals = [
|
let preferred = crate::settings::get_preferred_terminal();
|
||||||
|
|
||||||
|
// Default terminal list with their arguments
|
||||||
|
let default_terminals = [
|
||||||
("gnome-terminal", vec!["--"]),
|
("gnome-terminal", vec!["--"]),
|
||||||
("konsole", vec!["-e"]),
|
("konsole", vec!["-e"]),
|
||||||
("xfce4-terminal", vec!["-e"]),
|
("xfce4-terminal", vec!["-e"]),
|
||||||
@@ -655,9 +788,10 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
|||||||
("lxterminal", vec!["-e"]),
|
("lxterminal", vec!["-e"]),
|
||||||
("alacritty", vec!["-e"]),
|
("alacritty", vec!["-e"]),
|
||||||
("kitty", vec!["-e"]),
|
("kitty", vec!["-e"]),
|
||||||
|
("ghostty", vec!["-e"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create temp script file (same approach as macOS)
|
// Create temp script file
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||||
let config_path = config_file.to_string_lossy();
|
let config_path = config_file.to_string_lossy();
|
||||||
@@ -679,25 +813,48 @@ exec bash --norc --noprofile
|
|||||||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||||
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
.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, args) in terminals {
|
for (terminal, args) in terminals_to_try {
|
||||||
// Check if terminal exists
|
// 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()
|
||||||
|
|| which_command(terminal);
|
||||||
|
|
||||||
|
if terminal_exists {
|
||||||
let result = Command::new(terminal)
|
let result = Command::new(terminal)
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.arg("bash")
|
.arg("bash")
|
||||||
.arg(script_file.to_string_lossy().as_ref())
|
.arg(script_file.to_string_lossy().as_ref())
|
||||||
.output();
|
.spawn();
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(output) if output.status.success() => return Ok(()),
|
Ok(_) => return Ok(()),
|
||||||
Ok(output) => {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
last_error = format!("启动 {} 失败: {}", terminal, stderr);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
last_error = format!("执行 {} 失败: {}", terminal, e);
|
last_error = format!("执行 {} 失败: {}", terminal, e);
|
||||||
}
|
}
|
||||||
@@ -711,13 +868,25 @@ exec bash --norc --noprofile
|
|||||||
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('&', "^&");
|
||||||
@@ -733,23 +902,53 @@ del \"%~f0\" >nul 2>&1
|
|||||||
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}"))?;
|
||||||
|
|
||||||
|
let bat_path = bat_file.to_string_lossy();
|
||||||
|
let ps_cmd = format!("& '{}'", bat_path);
|
||||||
|
|
||||||
|
// Try the preferred terminal first
|
||||||
|
let result = match terminal {
|
||||||
|
"powershell" => run_windows_start_command(
|
||||||
|
&["powershell", "-NoExit", "-Command", &ps_cmd],
|
||||||
|
"PowerShell",
|
||||||
|
),
|
||||||
|
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
|
||||||
|
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
|
||||||
|
};
|
||||||
|
|
||||||
|
// If preferred terminal fails and it's not the default, try cmd as fallback
|
||||||
|
if result.is_err() && terminal != "cmd" {
|
||||||
|
log::warn!(
|
||||||
|
"首选终端 {} 启动失败,回退到 cmd: {:?}",
|
||||||
|
terminal,
|
||||||
|
result.as_ref().err()
|
||||||
|
);
|
||||||
|
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows: Run a start command with common error handling
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let mut full_args = vec!["/C", "start"];
|
||||||
|
full_args.extend(args);
|
||||||
|
|
||||||
// Use output() to capture errors from the start command
|
|
||||||
// Use /K instead of /C to keep the window open after execution
|
|
||||||
let output = Command::new("cmd")
|
let output = Command::new("cmd")
|
||||||
.args(["/C", "start", "cmd", "/K", &bat_file.to_string_lossy()])
|
.args(&full_args)
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("执行 cmd 失败: {e}"))?;
|
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
// Clean up on failure
|
|
||||||
let _ = std::fs::remove_file(&bat_file);
|
|
||||||
let _ = std::fs::remove_file(config_file);
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"启动 Windows 终端失败 (exit code: {:?}): {}",
|
"{} 启动失败 (exit code: {:?}): {}",
|
||||||
|
terminal_name,
|
||||||
output.status.code(),
|
output.status.code(),
|
||||||
stderr
|
stderr
|
||||||
));
|
));
|
||||||
@@ -757,3 +956,18 @@ del \"%~f0\" >nul 2>&1
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置窗口主题(Windows/macOS 标题栏颜色)
|
||||||
|
/// theme: "dark" | "light" | "system"
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<(), String> {
|
||||||
|
use tauri::Theme;
|
||||||
|
|
||||||
|
let tauri_theme = match theme.as_str() {
|
||||||
|
"dark" => Some(Theme::Dark),
|
||||||
|
"light" => Some(Theme::Light),
|
||||||
|
_ => None, // system default
|
||||||
|
};
|
||||||
|
|
||||||
|
window.set_theme(tauri_theme).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ mod global_proxy;
|
|||||||
mod import_export;
|
mod import_export;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod misc;
|
mod misc;
|
||||||
|
mod omo;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
|
mod session_manager;
|
||||||
mod settings;
|
mod settings;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
mod stream_check;
|
mod stream_check;
|
||||||
@@ -25,10 +27,12 @@ pub use global_proxy::*;
|
|||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
|
pub use omo::*;
|
||||||
pub use plugin::*;
|
pub use plugin::*;
|
||||||
pub use prompt::*;
|
pub use prompt::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
pub use proxy::*;
|
pub use proxy::*;
|
||||||
|
pub use session_manager::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use skill::*;
|
pub use skill::*;
|
||||||
pub use stream_check::*;
|
pub use stream_check::*;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::services::omo::OmoLocalFileData;
|
||||||
|
use crate::services::OmoService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
|
||||||
|
OmoService::read_local_file().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let provider = state
|
||||||
|
.db
|
||||||
|
.get_current_omo_provider("opencode")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(provider.map(|p| p.id).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let providers = state
|
||||||
|
.db
|
||||||
|
.get_all_providers("opencode")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
for (id, p) in &providers {
|
||||||
|
if p.category.as_deref() == Some("omo") {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.clear_omo_provider_current("opencode", id)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OmoService::delete_config_file().map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
|
let providers = state
|
||||||
|
.db
|
||||||
|
.get_all_providers("opencode")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let count = providers
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||||
|
.count();
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, Spee
|
|||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
/// 获取所有供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_providers(
|
pub fn get_providers(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -18,14 +17,12 @@ pub fn get_providers(
|
|||||||
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前供应商ID
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
|
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
|
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn add_provider(
|
pub fn add_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -36,7 +33,6 @@ pub fn add_provider(
|
|||||||
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_provider(
|
pub fn update_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -47,7 +43,6 @@ pub fn update_provider(
|
|||||||
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_provider(
|
pub fn delete_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -60,17 +55,18 @@ pub fn delete_provider(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
|
||||||
/// Does NOT delete from database - provider remains in the list
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
|
pub fn remove_provider_from_live_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::remove_from_live_config(app_type, &id)
|
ProviderService::remove_from_live_config(state.inner(), app_type, &id)
|
||||||
.map(|_| true)
|
.map(|_| true)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切换供应商
|
|
||||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
ProviderService::switch(state, app_type, id)
|
ProviderService::switch(state, app_type, id)
|
||||||
}
|
}
|
||||||
@@ -108,14 +104,12 @@ pub fn import_default_config_test_hook(
|
|||||||
import_default_config_internal(state, app_type)
|
import_default_config_internal(state, app_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导入当前配置为默认供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
import_default_config_internal(&state, app_type).map_err(Into::into)
|
import_default_config_internal(&state, app_type).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询供应商用量
|
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn queryProviderUsage(
|
pub async fn queryProviderUsage(
|
||||||
@@ -129,7 +123,6 @@ pub async fn queryProviderUsage(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
|
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -162,14 +155,12 @@ pub async fn testUsageScript(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取当前生效的配置内容
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
|
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试第三方/自定义供应商端点的网络延迟
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_api_endpoints(
|
pub async fn test_api_endpoints(
|
||||||
urls: Vec<String>,
|
urls: Vec<String>,
|
||||||
@@ -180,7 +171,6 @@ pub async fn test_api_endpoints(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取自定义端点列表
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_custom_endpoints(
|
pub fn get_custom_endpoints(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -192,7 +182,6 @@ pub fn get_custom_endpoints(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加自定义端点
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn add_custom_endpoint(
|
pub fn add_custom_endpoint(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -205,7 +194,6 @@ pub fn add_custom_endpoint(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除自定义端点
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn remove_custom_endpoint(
|
pub fn remove_custom_endpoint(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -218,7 +206,6 @@ pub fn remove_custom_endpoint(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新端点最后使用时间
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_endpoint_last_used(
|
pub fn update_endpoint_last_used(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -231,7 +218,6 @@ pub fn update_endpoint_last_used(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新多个供应商的排序
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_providers_sort_order(
|
pub fn update_providers_sort_order(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -242,24 +228,16 @@ pub fn update_providers_sort_order(
|
|||||||
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 统一供应商(Universal Provider)命令
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
use crate::provider::UniversalProvider;
|
use crate::provider::UniversalProvider;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
/// 统一供应商同步完成事件的 payload
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
#[derive(Clone, serde::Serialize)]
|
||||||
pub struct UniversalProviderSyncedEvent {
|
pub struct UniversalProviderSyncedEvent {
|
||||||
/// 操作类型: "upsert" | "delete" | "sync"
|
|
||||||
pub action: String,
|
pub action: String,
|
||||||
/// 统一供应商 ID
|
|
||||||
pub id: String,
|
pub id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发送统一供应商同步事件,通知前端刷新供应商列表
|
|
||||||
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"universal-provider-synced",
|
"universal-provider-synced",
|
||||||
@@ -270,7 +248,6 @@ fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取所有统一供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_universal_providers(
|
pub fn get_universal_providers(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -278,7 +255,6 @@ pub fn get_universal_providers(
|
|||||||
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
|
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取单个统一供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_universal_provider(
|
pub fn get_universal_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -287,7 +263,6 @@ pub fn get_universal_provider(
|
|||||||
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
|
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加或更新统一供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn upsert_universal_provider(
|
pub fn upsert_universal_provider(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -298,13 +273,11 @@ pub fn upsert_universal_provider(
|
|||||||
let result =
|
let result =
|
||||||
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
|
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 发送事件通知前端刷新
|
|
||||||
emit_universal_provider_synced(&app, "upsert", &id);
|
emit_universal_provider_synced(&app, "upsert", &id);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除统一供应商
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_universal_provider(
|
pub fn delete_universal_provider(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -314,13 +287,11 @@ pub fn delete_universal_provider(
|
|||||||
let result =
|
let result =
|
||||||
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
|
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 发送事件通知前端刷新
|
|
||||||
emit_universal_provider_synced(&app, "delete", &id);
|
emit_universal_provider_synced(&app, "delete", &id);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步统一供应商到各应用(手动触发)
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sync_universal_provider(
|
pub fn sync_universal_provider(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -330,29 +301,17 @@ pub fn sync_universal_provider(
|
|||||||
let result =
|
let result =
|
||||||
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
|
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 发送事件通知前端刷新
|
|
||||||
emit_universal_provider_synced(&app, "sync", &id);
|
emit_universal_provider_synced(&app, "sync", &id);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// OpenCode 专属命令
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 从 OpenCode live 配置导入供应商到数据库
|
|
||||||
///
|
|
||||||
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
|
|
||||||
/// 用户可能已经在 opencode.json 中配置了供应商。
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
crate::services::provider::import_opencode_providers_from_live(state.inner())
|
crate::services::provider::import_opencode_providers_from_live(state.inner())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 OpenCode live 配置中的供应商 ID 列表
|
|
||||||
///
|
|
||||||
/// 用于前端判断供应商是否已添加到 opencode.json
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||||
crate::opencode_config::get_providers()
|
crate::opencode_config::get_providers()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 提供前端调用的 API 接口
|
//! 提供前端调用的 API 接口
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
@@ -119,6 +120,120 @@ pub async fn update_proxy_config_for_app(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_default_cost_multiplier_internal(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_default_cost_multiplier(app_type).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
|
pub async fn get_default_cost_multiplier_test_hook(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
get_default_cost_multiplier_internal(state, app_type).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取默认成本倍率
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_default_cost_multiplier(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
get_default_cost_multiplier_internal(&state, &app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_default_cost_multiplier_internal(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.set_default_cost_multiplier(app_type, value).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
|
pub async fn set_default_cost_multiplier_test_hook(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
set_default_cost_multiplier_internal(state, app_type, value).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置默认成本倍率
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_default_cost_multiplier(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
value: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
set_default_cost_multiplier_internal(&state, &app_type, &value)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_pricing_model_source_internal(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_pricing_model_source(app_type).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
|
pub async fn get_pricing_model_source_test_hook(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
get_pricing_model_source_internal(state, app_type).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取计费模式来源
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_pricing_model_source(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
get_pricing_model_source_internal(&state, &app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_pricing_model_source_internal(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.set_pricing_model_source(app_type, value).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
|
pub async fn set_pricing_model_source_test_hook(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
set_pricing_model_source_internal(state, app_type, value).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置计费模式来源
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_pricing_model_source(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
value: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
set_pricing_model_source_internal(&state, &app_type, &value)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// 检查代理服务器是否正在运行
|
/// 检查代理服务器是否正在运行
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use crate::session_manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_sessions() -> Result<Vec<session_manager::SessionMeta>, String> {
|
||||||
|
let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to scan sessions: {e}"))?;
|
||||||
|
Ok(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_session_messages(
|
||||||
|
providerId: String,
|
||||||
|
sourcePath: String,
|
||||||
|
) -> Result<Vec<session_manager::SessionMessage>, String> {
|
||||||
|
let provider_id = providerId.clone();
|
||||||
|
let source_path = sourcePath.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
session_manager::load_messages(&provider_id, &source_path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to load session messages: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn launch_session_terminal(
|
||||||
|
command: String,
|
||||||
|
cwd: Option<String>,
|
||||||
|
custom_config: Option<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let command = command.clone();
|
||||||
|
let cwd = cwd.clone();
|
||||||
|
let custom_config = custom_config.clone();
|
||||||
|
|
||||||
|
// Read preferred terminal from global settings
|
||||||
|
let preferred = crate::settings::get_preferred_terminal();
|
||||||
|
// Map global setting terminal names to session terminal names
|
||||||
|
// Global uses "iterm2", session terminal uses "iterm"
|
||||||
|
let target = match preferred.as_deref() {
|
||||||
|
Some("iterm2") => "iterm".to_string(),
|
||||||
|
Some(t) => t.to_string(),
|
||||||
|
None => "terminal".to_string(), // Default to Terminal.app on macOS
|
||||||
|
};
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
session_manager::terminal::launch_terminal(
|
||||||
|
&target,
|
||||||
|
&command,
|
||||||
|
cwd.as_deref(),
|
||||||
|
custom_config.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to launch terminal: {e}"))??;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
@@ -249,3 +249,16 @@ pub fn remove_skill_repo(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 ZIP 文件安装 Skills
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn install_skills_from_zip(
|
||||||
|
file_path: String,
|
||||||
|
current_app: String,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<InstalledSkill>, String> {
|
||||||
|
let app_type = parse_app_type(¤t_app)?;
|
||||||
|
let path = std::path::Path::new(&file_path);
|
||||||
|
|
||||||
|
SkillService::install_from_zip(&app_state.db, path, &app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取应用配置文件路径
|
/// 获取应用配置文件路径
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
pub mod failover;
|
pub mod failover;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod omo;
|
||||||
pub mod prompts;
|
pub mod prompts;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
@@ -15,3 +16,4 @@ pub mod universal_providers;
|
|||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
// 导出 FailoverQueueItem 供外部使用
|
// 导出 FailoverQueueItem 供外部使用
|
||||||
pub use failover::FailoverQueueItem;
|
pub use failover::FailoverQueueItem;
|
||||||
|
pub use omo::OmoGlobalConfig;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use crate::database::Database;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OmoGlobalConfig {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub schema_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sisyphus_agent: Option<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disabled_agents: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disabled_mcps: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disabled_hooks: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disabled_skills: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lsp: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub experimental: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub background_task: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub browser_automation_engine: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub claude_code: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub other_fields: Option<serde_json::Value>,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OmoGlobalConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: "global".to_string(),
|
||||||
|
schema_url: None,
|
||||||
|
sisyphus_agent: None,
|
||||||
|
disabled_agents: vec![],
|
||||||
|
disabled_mcps: vec![],
|
||||||
|
disabled_hooks: vec![],
|
||||||
|
disabled_skills: vec![],
|
||||||
|
lsp: None,
|
||||||
|
experimental: None,
|
||||||
|
background_task: None,
|
||||||
|
browser_automation_engine: None,
|
||||||
|
claude_code: None,
|
||||||
|
other_fields: None,
|
||||||
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn get_omo_global_config(&self) -> Result<OmoGlobalConfig, AppError> {
|
||||||
|
let json_str = self.get_setting("common_config_omo")?;
|
||||||
|
match json_str {
|
||||||
|
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
|
||||||
|
.map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))),
|
||||||
|
None => Ok(OmoGlobalConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_omo_global_config(&self, config: &OmoGlobalConfig) -> Result<(), AppError> {
|
||||||
|
let json_str = serde_json::to_string(config)
|
||||||
|
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
|
||||||
|
self.set_setting("common_config_omo", &json_str)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
//! 供应商数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供供应商(Provider)的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
use crate::database::{lock_conn, Database};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::{Provider, ProviderMeta};
|
use crate::provider::{Provider, ProviderMeta};
|
||||||
@@ -9,8 +5,18 @@ use indexmap::IndexMap;
|
|||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
type OmoProviderRow = (
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
|
Option<usize>,
|
||||||
|
Option<String>,
|
||||||
|
String,
|
||||||
|
);
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 获取指定应用类型的所有供应商
|
|
||||||
pub fn get_all_providers(
|
pub fn get_all_providers(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
@@ -66,7 +72,6 @@ impl Database {
|
|||||||
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
|
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
provider.id = id.clone();
|
provider.id = id.clone();
|
||||||
|
|
||||||
// 加载 endpoints
|
|
||||||
let mut stmt_endpoints = conn.prepare(
|
let mut stmt_endpoints = conn.prepare(
|
||||||
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
|
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -103,7 +108,6 @@ impl Database {
|
|||||||
Ok(providers)
|
Ok(providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前激活的供应商 ID
|
|
||||||
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
@@ -123,7 +127,6 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据 ID 获取单个供应商
|
|
||||||
pub fn get_provider_by_id(
|
pub fn get_provider_by_id(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -174,21 +177,15 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存供应商(新增或更新)
|
|
||||||
///
|
|
||||||
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
|
||||||
/// (add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
|
|
||||||
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
|
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
|
||||||
let mut conn = lock_conn!(self.conn);
|
let mut conn = lock_conn!(self.conn);
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 处理 meta:取出 endpoints 以便单独处理
|
|
||||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||||
|
|
||||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
|
||||||
let existing: Option<(bool, bool)> = tx
|
let existing: Option<(bool, bool)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
@@ -202,7 +199,6 @@ impl Database {
|
|||||||
existing.unwrap_or((false, provider.in_failover_queue));
|
existing.unwrap_or((false, provider.in_failover_queue));
|
||||||
|
|
||||||
if is_update {
|
if is_update {
|
||||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE providers SET
|
"UPDATE providers SET
|
||||||
name = ?1,
|
name = ?1,
|
||||||
@@ -241,7 +237,6 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
} else {
|
} else {
|
||||||
// 新增模式:使用 INSERT
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO providers (
|
"INSERT INTO providers (
|
||||||
id, app_type, name, settings_config, website_url, category,
|
id, app_type, name, settings_config, website_url, category,
|
||||||
@@ -268,7 +263,6 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 只有新增时才同步 endpoints
|
|
||||||
for (url, endpoint) in endpoints {
|
for (url, endpoint) in endpoints {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
||||||
@@ -283,7 +277,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除供应商
|
|
||||||
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -294,21 +287,18 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置当前供应商
|
|
||||||
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||||
let mut conn = lock_conn!(self.conn);
|
let mut conn = lock_conn!(self.conn);
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 重置所有为 0
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
|
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
|
||||||
params![app_type],
|
params![app_type],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 设置新的当前供应商
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
|
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
|
||||||
params![id, app_type],
|
params![id, app_type],
|
||||||
@@ -319,7 +309,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
|
|
||||||
pub fn update_provider_settings_config(
|
pub fn update_provider_settings_config(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
@@ -341,7 +330,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加自定义端点
|
|
||||||
pub fn add_custom_endpoint(
|
pub fn add_custom_endpoint(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
@@ -357,7 +345,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 移除自定义端点
|
|
||||||
pub fn remove_custom_endpoint(
|
pub fn remove_custom_endpoint(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
@@ -372,4 +359,126 @@ impl Database {
|
|||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_omo_provider_current(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut conn = lock_conn!(self.conn);
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'",
|
||||||
|
params![app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let updated = tx
|
||||||
|
.execute(
|
||||||
|
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
||||||
|
params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
if updated != 1 {
|
||||||
|
return Err(AppError::Database(format!(
|
||||||
|
"Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_omo_provider_current(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
match conn.query_row(
|
||||||
|
"SELECT is_current FROM providers
|
||||||
|
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
||||||
|
params![provider_id, app_type],
|
||||||
|
|row| row.get(0),
|
||||||
|
) {
|
||||||
|
Ok(is_current) => Ok(is_current),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_omo_provider_current(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE providers SET is_current = 0
|
||||||
|
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
||||||
|
params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_current_omo_provider(&self, app_type: &str) -> Result<Option<Provider>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
|
||||||
|
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
|
||||||
|
FROM providers
|
||||||
|
WHERE app_type = ?1 AND category = 'omo' AND is_current = 1
|
||||||
|
LIMIT 1",
|
||||||
|
params![app_type],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get(0)?,
|
||||||
|
row.get(1)?,
|
||||||
|
row.get(2)?,
|
||||||
|
row.get(3)?,
|
||||||
|
row.get(4)?,
|
||||||
|
row.get(5)?,
|
||||||
|
row.get(6)?,
|
||||||
|
row.get(7)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) =
|
||||||
|
match row_data {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||||
|
Err(e) => return Err(AppError::Database(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
|
||||||
|
AppError::Database(format!(
|
||||||
|
"Failed to parse OMO provider settings_config (provider_id={id}): {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
|
||||||
|
crate::provider::ProviderMeta::default()
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(&meta_str).map_err(|e| {
|
||||||
|
AppError::Database(format!(
|
||||||
|
"Failed to parse OMO provider meta (provider_id={id}): {e}"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(Provider {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
settings_config,
|
||||||
|
website_url: None,
|
||||||
|
category,
|
||||||
|
created_at,
|
||||||
|
sort_index,
|
||||||
|
notes,
|
||||||
|
meta: Some(meta),
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
use super::super::{lock_conn, Database};
|
use super::super::{lock_conn, Database};
|
||||||
|
|
||||||
@@ -75,6 +76,117 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取默认成本倍率
|
||||||
|
pub async fn get_default_cost_multiplier(&self, app_type: &str) -> Result<String, AppError> {
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT default_cost_multiplier FROM proxy_config WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok("1".to_string())
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置默认成本倍率
|
||||||
|
pub async fn set_default_cost_multiplier(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"error.multiplierEmpty",
|
||||||
|
"倍率不能为空",
|
||||||
|
"Multiplier cannot be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
trimmed.parse::<Decimal>().map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"error.invalidMultiplier",
|
||||||
|
format!("无效倍率: {value} - {e}"),
|
||||||
|
format!("Invalid multiplier: {value} - {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 确保行存在
|
||||||
|
self.ensure_proxy_config_row_exists(app_type)?;
|
||||||
|
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
default_cost_multiplier = ?2,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE app_type = ?1",
|
||||||
|
rusqlite::params![app_type, trimmed],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取计费模式来源
|
||||||
|
pub async fn get_pricing_model_source(&self, app_type: &str) -> Result<String, AppError> {
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT pricing_model_source FROM proxy_config WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok("response".to_string())
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置计费模式来源
|
||||||
|
pub async fn set_pricing_model_source(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !matches!(trimmed, "response" | "request") {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"error.invalidPricingMode",
|
||||||
|
format!("无效计费模式: {value}"),
|
||||||
|
format!("Invalid pricing mode: {value}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保行存在
|
||||||
|
self.ensure_proxy_config_row_exists(app_type)?;
|
||||||
|
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
pricing_model_source = ?2,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE app_type = ?1",
|
||||||
|
rusqlite::params![app_type, trimmed],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取应用级代理配置
|
/// 获取应用级代理配置
|
||||||
pub async fn get_proxy_config_for_app(
|
pub async fn get_proxy_config_for_app(
|
||||||
&self,
|
&self,
|
||||||
@@ -177,17 +289,90 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 确保指定 app_type 的 proxy_config 行存在(同步版本,用于 set_* 函数)
|
||||||
|
///
|
||||||
|
/// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||||
|
fn ensure_proxy_config_row_exists(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Lock(e.to_string()))?;
|
||||||
|
|
||||||
|
// 根据 app_type 使用不同的默认值(与 schema.rs seed 保持一致)
|
||||||
|
let (retries, fb_timeout, idle_timeout, cb_fail, cb_succ, cb_timeout, cb_rate, cb_min) =
|
||||||
|
match app_type {
|
||||||
|
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
|
||||||
|
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
|
||||||
|
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
|
||||||
|
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
|
||||||
|
};
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO proxy_config (
|
||||||
|
app_type, max_retries,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, 600, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
rusqlite::params![
|
||||||
|
app_type,
|
||||||
|
retries,
|
||||||
|
fb_timeout,
|
||||||
|
idle_timeout,
|
||||||
|
cb_fail,
|
||||||
|
cb_succ,
|
||||||
|
cb_timeout,
|
||||||
|
cb_rate,
|
||||||
|
cb_min
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 初始化 proxy_config 表的三行数据
|
/// 初始化 proxy_config 表的三行数据
|
||||||
|
///
|
||||||
|
/// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||||
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
for app_type in &["claude", "codex", "gemini"] {
|
// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||||
conn.execute(
|
// claude: 更激进的重试和超时配置
|
||||||
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
|
conn.execute(
|
||||||
[app_type],
|
"INSERT OR IGNORE INTO proxy_config (
|
||||||
)
|
app_type, max_retries,
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
}
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
|
) VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// codex: 默认配置
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO proxy_config (
|
||||||
|
app_type, max_retries,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
|
) VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// gemini: 稍高的重试次数
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO proxy_config (
|
||||||
|
app_type, max_retries,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
|
) VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -662,3 +847,70 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_default_cost_multiplier_round_trip() -> Result<(), AppError> {
|
||||||
|
let db = Database::memory()?;
|
||||||
|
|
||||||
|
let default = db.get_default_cost_multiplier("claude").await?;
|
||||||
|
assert_eq!(default, "1");
|
||||||
|
|
||||||
|
db.set_default_cost_multiplier("claude", "1.5").await?;
|
||||||
|
let updated = db.get_default_cost_multiplier("claude").await?;
|
||||||
|
assert_eq!(updated, "1.5");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_default_cost_multiplier_validation() -> Result<(), AppError> {
|
||||||
|
let db = Database::memory()?;
|
||||||
|
|
||||||
|
let err = db
|
||||||
|
.set_default_cost_multiplier("claude", "not-a-number")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
// AppError::localized returns AppError::Localized variant
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
AppError::Localized {
|
||||||
|
key: "error.invalidMultiplier",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_pricing_model_source_round_trip_and_validation() -> Result<(), AppError> {
|
||||||
|
let db = Database::memory()?;
|
||||||
|
|
||||||
|
let default = db.get_pricing_model_source("claude").await?;
|
||||||
|
assert_eq!(default, "response");
|
||||||
|
|
||||||
|
db.set_pricing_model_source("claude", "request").await?;
|
||||||
|
let updated = db.get_pricing_model_source("claude").await?;
|
||||||
|
assert_eq!(updated, "request");
|
||||||
|
|
||||||
|
let err = db
|
||||||
|
.set_pricing_model_source("claude", "invalid")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
// AppError::localized returns AppError::Localized variant
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
AppError::Localized {
|
||||||
|
key: "error.invalidPricingMode",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ mod tests;
|
|||||||
|
|
||||||
// DAO 类型导出供外部使用
|
// DAO 类型导出供外部使用
|
||||||
pub use dao::FailoverQueueItem;
|
pub use dao::FailoverQueueItem;
|
||||||
|
pub use dao::OmoGlobalConfig;
|
||||||
|
|
||||||
use crate::config::get_app_config_dir;
|
use crate::config::get_app_config_dir;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -47,7 +48,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
|||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 4;
|
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||||
|
|
||||||
/// 安全地序列化 JSON,避免 unwrap panic
|
/// 安全地序列化 JSON,避免 unwrap panic
|
||||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ impl Database {
|
|||||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||||
|
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||||
|
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
@@ -170,6 +172,7 @@ impl Database {
|
|||||||
// 10. Proxy Request Logs 表
|
// 10. Proxy Request Logs 表
|
||||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||||
|
request_model TEXT,
|
||||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
@@ -352,6 +355,11 @@ impl Database {
|
|||||||
Self::migrate_v3_to_v4(conn)?;
|
Self::migrate_v3_to_v4(conn)?;
|
||||||
Self::set_user_version(conn, 4)?;
|
Self::set_user_version(conn, 4)?;
|
||||||
}
|
}
|
||||||
|
4 => {
|
||||||
|
log::info!("迁移数据库从 v4 到 v5(计费模式支持)");
|
||||||
|
Self::migrate_v4_to_v5(conn)?;
|
||||||
|
Self::set_user_version(conn, 5)?;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::Database(format!(
|
return Err(AppError::Database(format!(
|
||||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||||
@@ -521,6 +529,7 @@ impl Database {
|
|||||||
// proxy_request_logs 表
|
// proxy_request_logs 表
|
||||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||||
|
request_model TEXT,
|
||||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
@@ -677,6 +686,8 @@ impl Database {
|
|||||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||||
|
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||||
|
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)", [])?;
|
)", [])?;
|
||||||
|
|
||||||
@@ -879,12 +890,45 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v4 -> v5 迁移:新增计费模式配置与请求模型字段
|
||||||
|
fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
if Self::table_exists(conn, "proxy_config")? {
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"default_cost_multiplier",
|
||||||
|
"TEXT NOT NULL DEFAULT '1'",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"pricing_model_source",
|
||||||
|
"TEXT NOT NULL DEFAULT 'response'",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if Self::table_exists(conn, "proxy_request_logs")? {
|
||||||
|
Self::add_column_if_missing(conn, "proxy_request_logs", "request_model", "TEXT")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("v4 -> v5 迁移完成:已添加计费模式与请求模型字段");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 插入默认模型定价数据
|
/// 插入默认模型定价数据
|
||||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||||
let pricing_data = [
|
let pricing_data = [
|
||||||
// Claude 4.5 系列 (Latest Models)
|
// Claude 4.6 系列
|
||||||
|
(
|
||||||
|
"claude-opus-4-6-20260206",
|
||||||
|
"Claude Opus 4.6",
|
||||||
|
"5",
|
||||||
|
"25",
|
||||||
|
"0.50",
|
||||||
|
"6.25",
|
||||||
|
),
|
||||||
|
// Claude 4.5 系列
|
||||||
(
|
(
|
||||||
"claude-opus-4-5-20251101",
|
"claude-opus-4-5-20251101",
|
||||||
"Claude Opus 4.5",
|
"Claude Opus 4.5",
|
||||||
@@ -990,6 +1034,40 @@ impl Database {
|
|||||||
"0.175",
|
"0.175",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
|
// GPT-5.3 Codex 系列
|
||||||
|
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex-low",
|
||||||
|
"GPT-5.3 Codex",
|
||||||
|
"1.75",
|
||||||
|
"14",
|
||||||
|
"0.175",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex-medium",
|
||||||
|
"GPT-5.3 Codex",
|
||||||
|
"1.75",
|
||||||
|
"14",
|
||||||
|
"0.175",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex-high",
|
||||||
|
"GPT-5.3 Codex",
|
||||||
|
"1.75",
|
||||||
|
"14",
|
||||||
|
"0.175",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex-xhigh",
|
||||||
|
"GPT-5.3 Codex",
|
||||||
|
"1.75",
|
||||||
|
"14",
|
||||||
|
"0.175",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
// GPT-5.1 系列
|
// GPT-5.1 系列
|
||||||
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||||
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||||
@@ -1177,7 +1255,7 @@ impl Database {
|
|||||||
|
|
||||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO model_pricing (
|
"INSERT OR IGNORE INTO model_pricing (
|
||||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
@@ -1204,14 +1282,8 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||||||
let count: i64 = conn
|
// 每次启动都执行 INSERT OR IGNORE,增量追加新模型,已有数据不覆盖
|
||||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
Self::seed_model_pricing(conn)
|
||||||
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
|
|
||||||
|
|
||||||
if count == 0 {
|
|
||||||
Self::seed_model_pricing(conn)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 辅助方法 ---
|
// --- 辅助方法 ---
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_sets_user_version_when_missing() {
|
fn schema_migration_sets_user_version_when_missing() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||||
@@ -169,7 +169,7 @@ fn migration_sets_user_version_when_missing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_rejects_future_version() {
|
fn schema_migration_rejects_future_version() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||||
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
||||||
@@ -183,7 +183,7 @@ fn migration_rejects_future_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_adds_missing_columns_for_providers() {
|
fn schema_migration_adds_missing_columns_for_providers() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
// 创建旧版 providers 表,缺少新增列
|
// 创建旧版 providers 表,缺少新增列
|
||||||
@@ -224,7 +224,7 @@ fn migration_adds_missing_columns_for_providers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_aligns_column_defaults_and_types() {
|
fn schema_migration_aligns_column_defaults_and_types() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||||||
.expect("seed old schema");
|
.expect("seed old schema");
|
||||||
@@ -268,7 +268,67 @@ fn migration_aligns_column_defaults_and_types() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
fn schema_create_tables_include_pricing_model_columns() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||||
|
|
||||||
|
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
|
||||||
|
assert_eq!(multiplier.r#type, "TEXT");
|
||||||
|
assert_eq!(multiplier.notnull, 1);
|
||||||
|
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
|
||||||
|
|
||||||
|
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
|
||||||
|
assert_eq!(pricing_source.r#type, "TEXT");
|
||||||
|
assert_eq!(pricing_source.notnull, 1);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_default(&pricing_source.default).as_deref(),
|
||||||
|
Some("response")
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
|
||||||
|
assert_eq!(request_model.r#type, "TEXT");
|
||||||
|
assert_eq!(request_model.notnull, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_migration_v4_adds_pricing_model_columns() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
|
||||||
|
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("seed v4 schema");
|
||||||
|
|
||||||
|
Database::set_user_version(&conn, 4).expect("set user_version=4");
|
||||||
|
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||||
|
|
||||||
|
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
|
||||||
|
assert_eq!(multiplier.r#type, "TEXT");
|
||||||
|
assert_eq!(multiplier.notnull, 1);
|
||||||
|
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
|
||||||
|
|
||||||
|
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
|
||||||
|
assert_eq!(pricing_source.r#type, "TEXT");
|
||||||
|
assert_eq!(pricing_source.notnull, 1);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_default(&pricing_source.default).as_deref(),
|
||||||
|
Some("response")
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
|
||||||
|
assert_eq!(request_model.r#type, "TEXT");
|
||||||
|
assert_eq!(request_model.notnull, 0);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Database::get_user_version(&conn).expect("version after migration"),
|
||||||
|
SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
// 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type)
|
// 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type)
|
||||||
@@ -433,7 +493,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dry_run_does_not_write_to_disk() {
|
fn schema_dry_run_does_not_write_to_disk() {
|
||||||
// Create minimal valid config for migration
|
// Create minimal valid config for migration
|
||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
@@ -507,7 +567,7 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_pricing_is_seeded_on_init() {
|
fn schema_model_pricing_is_seeded_on_init() {
|
||||||
let db = Database::memory().expect("create memory db");
|
let db = Database::memory().expect("create memory db");
|
||||||
|
|
||||||
let conn = db.conn.lock().expect("lock conn");
|
let conn = db.conn.lock().expect("lock conn");
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ pub enum AppError {
|
|||||||
},
|
},
|
||||||
#[error("数据库错误: {0}")]
|
#[error("数据库错误: {0}")]
|
||||||
Database(String),
|
Database(String),
|
||||||
|
#[error("OMO 配置文件不存在")]
|
||||||
|
OmoConfigNotFound,
|
||||||
#[error("所有供应商已熔断,无可用渠道")]
|
#[error("所有供应商已熔断,无可用渠道")]
|
||||||
AllProvidersCircuitOpen,
|
AllProvidersCircuitOpen,
|
||||||
#[error("未配置供应商")]
|
#[error("未配置供应商")]
|
||||||
|
|||||||
@@ -1,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()
|
||||||
|
|||||||
@@ -21,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;
|
||||||
@@ -502,6 +503,28 @@ pub fn run() {
|
|||||||
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||||
|
{
|
||||||
|
let has_omo = app_state
|
||||||
|
.db
|
||||||
|
.get_all_providers("opencode")
|
||||||
|
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_omo {
|
||||||
|
match crate::services::OmoService::import_from_local(&app_state) {
|
||||||
|
Ok(provider) => {
|
||||||
|
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
|
||||||
|
}
|
||||||
|
Err(AppError::OmoConfigNotFound) => {
|
||||||
|
log::debug!("○ No OMO config to import");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("✗ Failed to import OMO config from local: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||||
log::info!("MCP table empty, importing from live configurations...");
|
log::info!("MCP table empty, importing from live configurations...");
|
||||||
@@ -745,6 +768,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![
|
||||||
@@ -832,6 +873,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,
|
||||||
@@ -861,6 +903,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,
|
||||||
@@ -877,6 +920,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,
|
||||||
@@ -909,6 +956,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,
|
||||||
@@ -927,6 +978,12 @@ pub fn run() {
|
|||||||
commands::test_proxy_url,
|
commands::test_proxy_url,
|
||||||
commands::get_upstream_proxy_status,
|
commands::get_upstream_proxy_status,
|
||||||
commands::scan_local_proxies,
|
commands::scan_local_proxies,
|
||||||
|
// Window theme control
|
||||||
|
commands::set_window_theme,
|
||||||
|
commands::read_omo_local_file,
|
||||||
|
commands::get_current_omo_provider_id,
|
||||||
|
commands::get_omo_provider_count,
|
||||||
|
commands::disable_current_omo,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
|
|||||||
@@ -1,26 +1,3 @@
|
|||||||
//! OpenCode 配置文件读写模块
|
|
||||||
//!
|
|
||||||
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
|
|
||||||
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
|
|
||||||
//!
|
|
||||||
//! ## 配置文件格式
|
|
||||||
//!
|
|
||||||
//! ```json
|
|
||||||
//! {
|
|
||||||
//! "$schema": "https://opencode.ai/config.json",
|
|
||||||
//! "provider": {
|
|
||||||
//! "my-provider": {
|
|
||||||
//! "npm": "@ai-sdk/openai-compatible",
|
|
||||||
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
|
|
||||||
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
|
|
||||||
//! }
|
|
||||||
//! },
|
|
||||||
//! "mcp": {
|
|
||||||
//! "my-server": { "type": "local", "command": ["..."] }
|
|
||||||
//! }
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use crate::config::write_json_file;
|
use crate::config::write_json_file;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::OpenCodeProviderConfig;
|
use crate::provider::OpenCodeProviderConfig;
|
||||||
@@ -29,52 +6,29 @@ use indexmap::IndexMap;
|
|||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Path Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 获取 OpenCode 配置目录
|
|
||||||
///
|
|
||||||
/// 默认路径: `~/.config/opencode/`
|
|
||||||
/// 可通过 settings.opencode_config_dir 覆盖
|
|
||||||
pub fn get_opencode_dir() -> PathBuf {
|
pub fn get_opencode_dir() -> PathBuf {
|
||||||
if let Some(override_dir) = get_opencode_override_dir() {
|
if let Some(override_dir) = get_opencode_override_dir() {
|
||||||
return override_dir;
|
return override_dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有平台统一使用 ~/.config/opencode
|
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.map(|h| h.join(".config").join("opencode"))
|
.map(|h| h.join(".config").join("opencode"))
|
||||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 OpenCode 配置文件路径
|
|
||||||
///
|
|
||||||
/// 返回 `~/.config/opencode/opencode.json`
|
|
||||||
pub fn get_opencode_config_path() -> PathBuf {
|
pub fn get_opencode_config_path() -> PathBuf {
|
||||||
get_opencode_dir().join("opencode.json")
|
get_opencode_dir().join("opencode.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 OpenCode 环境变量文件路径(如果存在)
|
|
||||||
///
|
|
||||||
/// 返回 `~/.config/opencode/.env`
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn get_opencode_env_path() -> PathBuf {
|
pub fn get_opencode_env_path() -> PathBuf {
|
||||||
get_opencode_dir().join(".env")
|
get_opencode_dir().join(".env")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Core Read/Write Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 读取 OpenCode 配置文件
|
|
||||||
///
|
|
||||||
/// 返回完整的配置 JSON 对象
|
|
||||||
pub fn read_opencode_config() -> Result<Value, AppError> {
|
pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||||
let path = get_opencode_config_path();
|
let path = get_opencode_config_path();
|
||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
// Return empty config with schema
|
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"$schema": "https://opencode.ai/config.json"
|
"$schema": "https://opencode.ai/config.json"
|
||||||
}));
|
}));
|
||||||
@@ -84,23 +38,14 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
|||||||
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写入 OpenCode 配置文件(原子写入)
|
|
||||||
///
|
|
||||||
/// 使用临时文件 + 重命名确保原子性
|
|
||||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||||
let path = get_opencode_config_path();
|
let path = get_opencode_config_path();
|
||||||
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
|
|
||||||
write_json_file(&path, config)?;
|
write_json_file(&path, config)?;
|
||||||
|
|
||||||
log::debug!("OpenCode config written to {path:?}");
|
log::debug!("OpenCode config written to {path:?}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Provider Functions (Untyped - for raw JSON operations)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 获取所有供应商配置(原始 JSON)
|
|
||||||
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||||
let config = read_opencode_config()?;
|
let config = read_opencode_config()?;
|
||||||
Ok(config
|
Ok(config
|
||||||
@@ -110,7 +55,6 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
|||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置供应商配置(原始 JSON)
|
|
||||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
let mut full_config = read_opencode_config()?;
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
@@ -128,7 +72,6 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
|||||||
write_opencode_config(&full_config)
|
write_opencode_config(&full_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除供应商配置
|
|
||||||
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||||
let mut config = read_opencode_config()?;
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
@@ -139,11 +82,6 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
|||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Provider Functions (Typed - using OpenCodeProviderConfig)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 获取所有供应商配置(类型化)
|
|
||||||
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
||||||
let providers = get_providers()?;
|
let providers = get_providers()?;
|
||||||
let mut result = IndexMap::new();
|
let mut result = IndexMap::new();
|
||||||
@@ -155,7 +93,6 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Failed to parse provider '{id}': {e}");
|
log::warn!("Failed to parse provider '{id}': {e}");
|
||||||
// Skip invalid providers but continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,17 +100,11 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置供应商配置(类型化)
|
|
||||||
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
|
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
|
||||||
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
set_provider(id, value)
|
set_provider(id, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MCP Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 获取所有 MCP 服务器配置
|
|
||||||
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
||||||
let config = read_opencode_config()?;
|
let config = read_opencode_config()?;
|
||||||
Ok(config
|
Ok(config
|
||||||
@@ -183,7 +114,6 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
|||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 MCP 服务器配置
|
|
||||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
let mut full_config = read_opencode_config()?;
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
@@ -198,7 +128,6 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
|||||||
write_opencode_config(&full_config)
|
write_opencode_config(&full_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除 MCP 服务器配置
|
|
||||||
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||||
let mut config = read_opencode_config()?;
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
@@ -208,3 +137,57 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
||||||
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
|
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
|
||||||
|
|
||||||
|
match plugins {
|
||||||
|
Some(arr) => {
|
||||||
|
if plugin_name.starts_with("oh-my-opencode")
|
||||||
|
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||||
|
{
|
||||||
|
arr.retain(|v| {
|
||||||
|
v.as_str()
|
||||||
|
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||||
|
.unwrap_or(true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||||
|
if !already_exists {
|
||||||
|
arr.push(Value::String(plugin_name.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
config["plugin"] = json!([plugin_name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
|
||||||
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
|
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
|
||||||
|
arr.retain(|v| {
|
||||||
|
v.as_str()
|
||||||
|
.map(|s| {
|
||||||
|
if !s.starts_with(prefix) {
|
||||||
|
return true; // Keep: doesn't match prefix at all
|
||||||
|
}
|
||||||
|
let rest = &s[prefix.len()..];
|
||||||
|
rest.starts_with('-')
|
||||||
|
})
|
||||||
|
.unwrap_or(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
if arr.is_empty() {
|
||||||
|
config.as_object_mut().map(|obj| obj.remove("plugin"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&config)
|
||||||
|
}
|
||||||
|
|||||||
+326
-4
@@ -215,6 +215,9 @@ pub struct ProviderMeta {
|
|||||||
/// 成本倍数(用于计算实际成本)
|
/// 成本倍数(用于计算实际成本)
|
||||||
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
|
||||||
pub cost_multiplier: Option<String>,
|
pub cost_multiplier: Option<String>,
|
||||||
|
/// 计费模式来源(response/request)
|
||||||
|
#[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pricing_model_source: Option<String>,
|
||||||
/// 每日消费限额(USD)
|
/// 每日消费限额(USD)
|
||||||
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
|
||||||
pub limit_daily_usd: Option<String>,
|
pub limit_daily_usd: Option<String>,
|
||||||
@@ -227,6 +230,11 @@ pub struct ProviderMeta {
|
|||||||
/// 供应商单独的代理配置
|
/// 供应商单独的代理配置
|
||||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||||
pub proxy_config: Option<ProviderProxyConfig>,
|
pub proxy_config: Option<ProviderProxyConfig>,
|
||||||
|
/// Claude API 格式(仅 Claude 供应商使用)
|
||||||
|
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||||
|
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||||
|
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
@@ -438,11 +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 内容
|
||||||
@@ -614,3 +629,310 @@ pub struct OpenCodeModelLimit {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub output: Option<u64>,
|
pub output: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -666,7 +666,11 @@ impl RequestForwarder {
|
|||||||
|
|
||||||
// 输出请求信息日志
|
// 输出请求信息日志
|
||||||
let tag = adapter.name();
|
let tag = adapter.name();
|
||||||
log::debug!("[{tag}] >>> 请求 URL: {url}");
|
let request_model = filtered_body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("<none>");
|
||||||
|
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
|
||||||
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ use super::{
|
|||||||
};
|
};
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 健康检查和状态查询(简单端点)
|
// 健康检查和状态查询(简单端点)
|
||||||
@@ -145,6 +143,7 @@ async fn handle_claude_transform(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
"claude",
|
"claude",
|
||||||
&model,
|
&model,
|
||||||
|
&model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
first_token_ms,
|
first_token_ms,
|
||||||
@@ -215,6 +214,7 @@ async fn handle_claude_transform(
|
|||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
let latency_ms = ctx.latency_ms();
|
let latency_ms = ctx.latency_ms();
|
||||||
|
|
||||||
|
let request_model = ctx.request_model.clone();
|
||||||
tokio::spawn({
|
tokio::spawn({
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let provider_id = ctx.provider.id.clone();
|
let provider_id = ctx.provider.id.clone();
|
||||||
@@ -225,6 +225,7 @@ async fn handle_claude_transform(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
"claude",
|
"claude",
|
||||||
&model,
|
&model,
|
||||||
|
&request_model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
None,
|
None,
|
||||||
@@ -283,7 +284,7 @@ pub async fn handle_chat_completions(
|
|||||||
let result = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/chat/completions",
|
"/chat/completions",
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
ctx.get_providers(),
|
ctx.get_providers(),
|
||||||
@@ -324,7 +325,7 @@ pub async fn handle_responses(
|
|||||||
let result = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/responses",
|
"/responses",
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
ctx.get_providers(),
|
ctx.get_providers(),
|
||||||
@@ -441,6 +442,7 @@ async fn log_usage(
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
request_model: &str,
|
||||||
usage: TokenUsage,
|
usage: TokenUsage,
|
||||||
latency_ms: u64,
|
latency_ms: u64,
|
||||||
first_token_ms: Option<u64>,
|
first_token_ms: Option<u64>,
|
||||||
@@ -451,25 +453,12 @@ async fn log_usage(
|
|||||||
|
|
||||||
let logger = UsageLogger::new(&state.db);
|
let logger = UsageLogger::new(&state.db);
|
||||||
|
|
||||||
// 获取 provider 的 cost_multiplier
|
let (multiplier, pricing_model_source) =
|
||||||
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||||
Ok(Some(p)) => {
|
let pricing_model = if pricing_model_source == "request" {
|
||||||
if let Some(meta) = p.meta {
|
request_model
|
||||||
if let Some(cm) = meta.cost_multiplier {
|
} else {
|
||||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
model
|
||||||
log::warn!(
|
|
||||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
|
||||||
);
|
|
||||||
Decimal::from(1)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Decimal::from(1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Decimal::from(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Decimal::from(1),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let request_id = uuid::Uuid::new_v4().to_string();
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
@@ -479,6 +468,8 @@ async fn log_usage(
|
|||||||
provider_id.to_string(),
|
provider_id.to_string(),
|
||||||
app_type.to_string(),
|
app_type.to_string(),
|
||||||
model.to_string(),
|
model.to_string(),
|
||||||
|
request_model.to_string(),
|
||||||
|
pricing_model.to_string(),
|
||||||
usage,
|
usage,
|
||||||
multiplier,
|
multiplier,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
|
|||||||
@@ -17,6 +17,33 @@ static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
|
|||||||
/// 当前代理 URL(用于日志和状态查询)
|
/// 当前代理 URL(用于日志和状态查询)
|
||||||
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
|
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
|
||||||
|
|
||||||
|
/// CC Switch 代理服务器当前监听的端口
|
||||||
|
static CC_SWITCH_PROXY_PORT: OnceCell<RwLock<u16>> = OnceCell::new();
|
||||||
|
|
||||||
|
/// 设置 CC Switch 代理服务器的监听端口
|
||||||
|
///
|
||||||
|
/// 应在代理服务器启动时调用,以便系统代理检测能正确识别自己的端口
|
||||||
|
pub fn set_proxy_port(port: u16) {
|
||||||
|
if let Some(lock) = CC_SWITCH_PROXY_PORT.get() {
|
||||||
|
if let Ok(mut current_port) = lock.write() {
|
||||||
|
*current_port = port;
|
||||||
|
log::debug!("[GlobalProxy] Updated CC Switch proxy port to {port}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = CC_SWITCH_PROXY_PORT.set(RwLock::new(port));
|
||||||
|
log::debug!("[GlobalProxy] Initialized CC Switch proxy port to {port}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 CC Switch 代理服务器的监听端口
|
||||||
|
fn get_proxy_port() -> u16 {
|
||||||
|
CC_SWITCH_PROXY_PORT
|
||||||
|
.get()
|
||||||
|
.and_then(|lock| lock.read().ok())
|
||||||
|
.map(|port| *port)
|
||||||
|
.unwrap_or(15721) // 默认端口作为回退
|
||||||
|
}
|
||||||
|
|
||||||
/// 初始化全局 HTTP 客户端
|
/// 初始化全局 HTTP 客户端
|
||||||
///
|
///
|
||||||
/// 应在应用启动时调用一次。
|
/// 应在应用启动时调用一次。
|
||||||
@@ -258,9 +285,17 @@ fn proxy_points_to_loopback(value: &str) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否指向 CC Switch 自己的代理端口
|
||||||
|
// 只有指向自己的代理才需要跳过,避免递归
|
||||||
|
fn is_cc_switch_proxy_port(port: Option<u16>) -> bool {
|
||||||
|
let cc_switch_port = get_proxy_port();
|
||||||
|
port == Some(cc_switch_port)
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(parsed) = url::Url::parse(value) {
|
if let Ok(parsed) = url::Url::parse(value) {
|
||||||
if let Some(host) = parsed.host_str() {
|
if let Some(host) = parsed.host_str() {
|
||||||
return host_is_loopback(host);
|
// 只有当主机是 loopback 且端口是 CC Switch 的端口时才返回 true
|
||||||
|
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -268,7 +303,7 @@ fn proxy_points_to_loopback(value: &str) -> bool {
|
|||||||
let with_scheme = format!("http://{value}");
|
let with_scheme = format!("http://{value}");
|
||||||
if let Ok(parsed) = url::Url::parse(&with_scheme) {
|
if let Ok(parsed) = url::Url::parse(&with_scheme) {
|
||||||
if let Some(host) = parsed.host_str() {
|
if let Some(host) = parsed.host_str() {
|
||||||
return host_is_loopback(host);
|
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,16 +483,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_proxy_points_to_loopback() {
|
fn test_proxy_points_to_loopback() {
|
||||||
assert!(proxy_points_to_loopback("http://127.0.0.1:7890"));
|
// 设置 CC Switch 代理端口为 15721(默认值)
|
||||||
assert!(proxy_points_to_loopback("socks5://localhost:1080"));
|
set_proxy_port(15721);
|
||||||
assert!(proxy_points_to_loopback("127.0.0.1:7890"));
|
|
||||||
|
// 只有指向 CC Switch 自己端口的 loopback 地址才返回 true
|
||||||
|
assert!(proxy_points_to_loopback("http://127.0.0.1:15721"));
|
||||||
|
assert!(proxy_points_to_loopback("socks5://localhost:15721"));
|
||||||
|
assert!(proxy_points_to_loopback("127.0.0.1:15721"));
|
||||||
|
|
||||||
|
// 其他 loopback 端口不应该被跳过(允许使用其他本地代理工具)
|
||||||
|
assert!(!proxy_points_to_loopback("http://127.0.0.1:7890"));
|
||||||
|
assert!(!proxy_points_to_loopback("socks5://localhost:1080"));
|
||||||
|
|
||||||
|
// 非 loopback 地址不应该被跳过
|
||||||
assert!(!proxy_points_to_loopback("http://192.168.1.10:7890"));
|
assert!(!proxy_points_to_loopback("http://192.168.1.10:7890"));
|
||||||
|
assert!(!proxy_points_to_loopback("http://192.168.1.10:15721"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_system_proxy_points_to_loopback() {
|
fn test_system_proxy_points_to_loopback() {
|
||||||
let _guard = env_lock().lock().unwrap();
|
let _guard = env_lock().lock().unwrap();
|
||||||
|
|
||||||
|
// 设置 CC Switch 代理端口
|
||||||
|
set_proxy_port(15721);
|
||||||
|
|
||||||
let keys = [
|
let keys = [
|
||||||
"HTTP_PROXY",
|
"HTTP_PROXY",
|
||||||
"http_proxy",
|
"http_proxy",
|
||||||
@@ -471,9 +520,15 @@ mod tests {
|
|||||||
std::env::remove_var(key);
|
std::env::remove_var(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
|
// 指向 CC Switch 端口的代理应该被跳过
|
||||||
|
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:15721");
|
||||||
assert!(system_proxy_points_to_loopback());
|
assert!(system_proxy_points_to_loopback());
|
||||||
|
|
||||||
|
// 指向其他端口的本地代理不应该被跳过
|
||||||
|
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
|
||||||
|
assert!(!system_proxy_points_to_loopback());
|
||||||
|
|
||||||
|
// 非 loopback 地址不应该被跳过
|
||||||
std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890");
|
std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890");
|
||||||
assert!(!system_proxy_points_to_loopback());
|
assert!(!system_proxy_points_to_loopback());
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
//! Claude (Anthropic) Provider Adapter
|
//! Claude (Anthropic) Provider Adapter
|
||||||
//!
|
//!
|
||||||
//! 支持透传模式和 OpenRouter 兼容模式
|
//! 支持透传模式和 OpenAI Chat Completions 格式转换模式
|
||||||
|
//!
|
||||||
|
//! ## API 格式
|
||||||
|
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||||
|
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||||
//!
|
//!
|
||||||
//! ## 认证模式
|
//! ## 认证模式
|
||||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
|
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
|
||||||
|
|
||||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -48,22 +52,52 @@ impl ClaudeAdapter {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检测 OpenRouter 是否启用兼容模式
|
/// 获取 API 格式
|
||||||
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
|
///
|
||||||
if !self.is_openrouter(provider) {
|
/// 从 provider.meta.api_format 读取格式设置:
|
||||||
return false;
|
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||||
|
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||||
|
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||||||
|
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||||
|
if let Some(meta) = provider.meta.as_ref() {
|
||||||
|
if let Some(api_format) = meta.api_format.as_deref() {
|
||||||
|
return if api_format == "openai_chat" {
|
||||||
|
"openai_chat"
|
||||||
|
} else {
|
||||||
|
"anthropic"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2) Backward compatibility: legacy settings_config.api_format
|
||||||
|
if let Some(api_format) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("api_format")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return if api_format == "openai_chat" {
|
||||||
|
"openai_chat"
|
||||||
|
} else {
|
||||||
|
"anthropic"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||||
match raw {
|
let enabled = match raw {
|
||||||
Some(serde_json::Value::Bool(enabled)) => *enabled,
|
Some(serde_json::Value::Bool(v)) => *v,
|
||||||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||||
Some(serde_json::Value::String(value)) => {
|
Some(serde_json::Value::String(value)) => {
|
||||||
let normalized = value.trim().to_lowercase();
|
let normalized = value.trim().to_lowercase();
|
||||||
normalized == "true" || normalized == "1"
|
normalized == "true" || normalized == "1"
|
||||||
}
|
}
|
||||||
// OpenRouter now supports Claude Code compatible API, default to passthrough
|
|
||||||
_ => false,
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
"openai_chat"
|
||||||
|
} else {
|
||||||
|
"anthropic"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,15 +252,23 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||||
|
|
||||||
let base = format!(
|
let mut base = format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
base_url.trim_end_matches('/'),
|
base_url.trim_end_matches('/'),
|
||||||
endpoint.trim_start_matches('/')
|
endpoint.trim_start_matches('/')
|
||||||
);
|
);
|
||||||
|
|
||||||
// 为 /v1/messages 端点添加 ?beta=true 参数
|
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
|
||||||
|
while base.contains("/v1/v1") {
|
||||||
|
base = base.replace("/v1/v1", "/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为 Claude 相关端点添加 ?beta=true 参数
|
||||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||||
if endpoint.contains("/v1/messages") && !endpoint.contains("?") {
|
// 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
|
||||||
|
if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions"))
|
||||||
|
&& !endpoint.contains('?')
|
||||||
|
{
|
||||||
format!("{base}?beta=true")
|
format!("{base}?beta=true")
|
||||||
} else {
|
} else {
|
||||||
base
|
base
|
||||||
@@ -253,21 +295,19 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||||
// NOTE:
|
// 根据 api_format 配置决定是否需要格式转换
|
||||||
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
|
// - "anthropic" (默认): 直接透传,无需转换
|
||||||
// Anthropic ↔ OpenAI 的格式转换。
|
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
|
||||||
//
|
self.get_api_format(provider) == "openai_chat"
|
||||||
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
|
||||||
self.is_openrouter_compat_enabled(_provider)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_request(
|
fn transform_request(
|
||||||
&self,
|
&self,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
provider: &Provider,
|
_provider: &Provider,
|
||||||
) -> Result<serde_json::Value, ProxyError> {
|
) -> Result<serde_json::Value, ProxyError> {
|
||||||
super::transform::anthropic_to_openai(body, provider)
|
super::transform::anthropic_to_openai(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||||
@@ -278,6 +318,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::provider::ProviderMeta;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn create_provider(config: serde_json::Value) -> Provider {
|
fn create_provider(config: serde_json::Value) -> Provider {
|
||||||
@@ -297,6 +338,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_provider_with_meta(config: serde_json::Value, meta: ProviderMeta) -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test Claude".to_string(),
|
||||||
|
settings_config: config,
|
||||||
|
website_url: None,
|
||||||
|
category: Some("claude".to_string()),
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: Some(meta),
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_base_url_from_env() {
|
fn test_extract_base_url_from_env() {
|
||||||
let adapter = ClaudeAdapter::new();
|
let adapter = ClaudeAdapter::new();
|
||||||
@@ -459,6 +517,7 @@ mod tests {
|
|||||||
fn test_needs_transform() {
|
fn test_needs_transform() {
|
||||||
let adapter = ClaudeAdapter::new();
|
let adapter = ClaudeAdapter::new();
|
||||||
|
|
||||||
|
// Default: no transform (anthropic format) - no meta
|
||||||
let anthropic_provider = create_provider(json!({
|
let anthropic_provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||||
@@ -466,29 +525,96 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||||
|
|
||||||
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
|
// Explicit anthropic format in meta: no transform
|
||||||
let openrouter_provider = create_provider(json!({
|
let explicit_anthropic = create_provider_with_meta(
|
||||||
"env": {
|
json!({
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
"env": {
|
||||||
}
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
}));
|
}
|
||||||
assert!(!adapter.needs_transform(&openrouter_provider));
|
}),
|
||||||
|
ProviderMeta {
|
||||||
|
api_format: Some("anthropic".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!adapter.needs_transform(&explicit_anthropic));
|
||||||
|
|
||||||
// OpenRouter provider with explicit compat mode enabled should transform
|
// Legacy settings_config.api_format: openai_chat should enable transform
|
||||||
let openrouter_enabled = create_provider(json!({
|
let legacy_settings_api_format = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
|
},
|
||||||
|
"api_format": "openai_chat"
|
||||||
|
}));
|
||||||
|
assert!(adapter.needs_transform(&legacy_settings_api_format));
|
||||||
|
|
||||||
|
// Legacy openrouter_compat_mode: bool/number/string should enable transform
|
||||||
|
let legacy_openrouter_bool = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
},
|
},
|
||||||
"openrouter_compat_mode": true
|
"openrouter_compat_mode": true
|
||||||
}));
|
}));
|
||||||
assert!(adapter.needs_transform(&openrouter_enabled));
|
assert!(adapter.needs_transform(&legacy_openrouter_bool));
|
||||||
|
|
||||||
let openrouter_disabled = create_provider(json!({
|
let legacy_openrouter_num = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
},
|
},
|
||||||
"openrouter_compat_mode": false
|
"openrouter_compat_mode": 1
|
||||||
}));
|
}));
|
||||||
assert!(!adapter.needs_transform(&openrouter_disabled));
|
assert!(adapter.needs_transform(&legacy_openrouter_num));
|
||||||
|
|
||||||
|
let legacy_openrouter_str = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
|
},
|
||||||
|
"openrouter_compat_mode": "true"
|
||||||
|
}));
|
||||||
|
assert!(adapter.needs_transform(&legacy_openrouter_str));
|
||||||
|
|
||||||
|
// OpenAI Chat format in meta: needs transform
|
||||||
|
let openai_chat_provider = create_provider_with_meta(
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ProviderMeta {
|
||||||
|
api_format: Some("openai_chat".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(adapter.needs_transform(&openai_chat_provider));
|
||||||
|
|
||||||
|
// meta takes precedence over legacy settings_config fields
|
||||||
|
let meta_precedence_over_settings = create_provider_with_meta(
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
|
},
|
||||||
|
"api_format": "openai_chat",
|
||||||
|
"openrouter_compat_mode": true
|
||||||
|
}),
|
||||||
|
ProviderMeta {
|
||||||
|
api_format: Some("anthropic".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!adapter.needs_transform(&meta_precedence_over_settings));
|
||||||
|
|
||||||
|
// Unknown format in meta: default to anthropic (no transform)
|
||||||
|
let unknown_format = create_provider_with_meta(
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ProviderMeta {
|
||||||
|
api_format: Some("unknown".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!adapter.needs_transform(&unknown_format));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,10 +141,33 @@ impl ProviderAdapter for CodexAdapter {
|
|||||||
let base_trimmed = base_url.trim_end_matches('/');
|
let base_trimmed = base_url.trim_end_matches('/');
|
||||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||||
|
|
||||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
// OpenAI/Codex 的 base_url 可能是:
|
||||||
|
// - 纯 origin: https://api.openai.com (需要自动补 /v1)
|
||||||
|
// - 已含 /v1: https://api.openai.com/v1 (直接拼接)
|
||||||
|
// - 自定义前缀: https://xxx/openai (不添加 /v1,直接拼接)
|
||||||
|
|
||||||
// 去除重复的 /v1/v1
|
// 检查 base_url 是否已经包含 /v1
|
||||||
if url.contains("/v1/v1") {
|
let already_has_v1 = base_trimmed.ends_with("/v1");
|
||||||
|
|
||||||
|
// 检查是否是纯 origin(没有路径部分)
|
||||||
|
let origin_only = match base_trimmed.split_once("://") {
|
||||||
|
Some((_scheme, rest)) => !rest.contains('/'),
|
||||||
|
None => !base_trimmed.contains('/'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut url = if already_has_v1 {
|
||||||
|
// 已经有 /v1,直接拼接
|
||||||
|
format!("{base_trimmed}/{endpoint_trimmed}")
|
||||||
|
} else if origin_only {
|
||||||
|
// 纯 origin,添加 /v1
|
||||||
|
format!("{base_trimmed}/v1/{endpoint_trimmed}")
|
||||||
|
} else {
|
||||||
|
// 自定义前缀,不添加 /v1,直接拼接
|
||||||
|
format!("{base_trimmed}/{endpoint_trimmed}")
|
||||||
|
};
|
||||||
|
|
||||||
|
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
|
||||||
|
while url.contains("/v1/v1") {
|
||||||
url = url.replace("/v1/v1", "/v1");
|
url = url.replace("/v1/v1", "/v1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +246,20 @@ mod tests {
|
|||||||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_origin_adds_v1() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
let url = adapter.build_url("https://api.openai.com", "/responses");
|
||||||
|
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_custom_prefix_no_v1() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
let url = adapter.build_url("https://example.com/openai", "/responses");
|
||||||
|
assert_eq!(url, "https://example.com/openai/responses");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_url_dedup_v1() {
|
fn test_build_url_dedup_v1() {
|
||||||
let adapter = CodexAdapter::new();
|
let adapter = CodexAdapter::new();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! 用于 Anthropic Messages API 的请求/响应格式转换
|
//! 用于 Anthropic Messages API 的请求/响应格式转换
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
|
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
@@ -3,77 +3,16 @@
|
|||||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||||
//! 参考: anthropic-proxy-rs
|
//! 参考: anthropic-proxy-rs
|
||||||
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::proxy::error::ProxyError;
|
use crate::proxy::error::ProxyError;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
/// 从 Provider 配置中获取模型映射
|
|
||||||
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
|
|
||||||
let env = provider.settings_config.get("env");
|
|
||||||
let model_lower = model.to_lowercase();
|
|
||||||
|
|
||||||
// 检测 thinking 参数
|
|
||||||
let has_thinking = body
|
|
||||||
.get("thinking")
|
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.and_then(|o| o.get("type"))
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
== Some("enabled");
|
|
||||||
|
|
||||||
if let Some(env) = env {
|
|
||||||
// 如果启用 thinking,优先使用推理模型
|
|
||||||
if has_thinking {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_REASONING_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
log::debug!("[Transform] 使用推理模型: {m}");
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据模型类型选择配置模型
|
|
||||||
if model_lower.contains("haiku") {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if model_lower.contains("opus") {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if model_lower.contains("sonnet") {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 默认使用 ANTHROPIC_MODEL
|
|
||||||
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
model.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Anthropic 请求 → OpenAI 请求
|
/// Anthropic 请求 → OpenAI 请求
|
||||||
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
|
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||||
let mut result = json!({});
|
let mut result = json!({});
|
||||||
|
|
||||||
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
|
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||||
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
||||||
let mapped_model = get_model_from_provider(model, provider, &body);
|
result["model"] = json!(model);
|
||||||
result["model"] = json!(mapped_model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
@@ -381,45 +320,16 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn create_provider(env_config: Value) -> Provider {
|
|
||||||
Provider {
|
|
||||||
id: "test".to_string(),
|
|
||||||
name: "Test Provider".to_string(),
|
|
||||||
settings_config: json!({"env": env_config}),
|
|
||||||
website_url: None,
|
|
||||||
category: None,
|
|
||||||
created_at: None,
|
|
||||||
sort_index: None,
|
|
||||||
notes: None,
|
|
||||||
meta: None,
|
|
||||||
icon: None,
|
|
||||||
icon_color: None,
|
|
||||||
in_failover_queue: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_openrouter_provider() -> Provider {
|
|
||||||
create_provider(json!({
|
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
|
||||||
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
|
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
|
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
|
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_simple() {
|
fn test_anthropic_to_openai_simple() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
|
assert_eq!(result["model"], "claude-3-opus");
|
||||||
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
|
|
||||||
assert_eq!(result["max_tokens"], 1024);
|
assert_eq!(result["max_tokens"], 1024);
|
||||||
assert_eq!(result["messages"][0]["role"], "user");
|
assert_eq!(result["messages"][0]["role"], "user");
|
||||||
assert_eq!(result["messages"][0]["content"], "Hello");
|
assert_eq!(result["messages"][0]["content"], "Hello");
|
||||||
@@ -427,7 +337,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_with_system() {
|
fn test_anthropic_to_openai_with_system() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-sonnet",
|
"model": "claude-3-sonnet",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
@@ -435,7 +344,7 @@ mod tests {
|
|||||||
"messages": [{"role": "user", "content": "Hello"}]
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
assert_eq!(result["messages"][0]["role"], "system");
|
assert_eq!(result["messages"][0]["role"], "system");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result["messages"][0]["content"],
|
result["messages"][0]["content"],
|
||||||
@@ -446,7 +355,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_with_tools() {
|
fn test_anthropic_to_openai_with_tools() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
@@ -458,14 +366,13 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
assert_eq!(result["tools"][0]["type"], "function");
|
assert_eq!(result["tools"][0]["type"], "function");
|
||||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_tool_use() {
|
fn test_anthropic_to_openai_tool_use() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
@@ -478,7 +385,7 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
let msg = &result["messages"][0];
|
let msg = &result["messages"][0];
|
||||||
assert_eq!(msg["role"], "assistant");
|
assert_eq!(msg["role"], "assistant");
|
||||||
assert!(msg.get("tool_calls").is_some());
|
assert!(msg.get("tool_calls").is_some());
|
||||||
@@ -487,7 +394,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_openai_tool_result() {
|
fn test_anthropic_to_openai_tool_result() {
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-3-opus",
|
"model": "claude-3-opus",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
@@ -499,7 +405,7 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
let msg = &result["messages"][0];
|
let msg = &result["messages"][0];
|
||||||
assert_eq!(msg["role"], "tool");
|
assert_eq!(msg["role"], "tool");
|
||||||
assert_eq!(msg["tool_call_id"], "call_123");
|
assert_eq!(msg["tool_call_id"], "call_123");
|
||||||
@@ -563,78 +469,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_model_mapping_from_provider() {
|
fn test_model_passthrough() {
|
||||||
let provider = create_openrouter_provider();
|
// 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理
|
||||||
let body = json!({"model": "test"});
|
|
||||||
|
|
||||||
// sonnet 模型
|
|
||||||
assert_eq!(
|
|
||||||
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
|
|
||||||
"anthropic/claude-sonnet-4.5"
|
|
||||||
);
|
|
||||||
|
|
||||||
// haiku 模型
|
|
||||||
assert_eq!(
|
|
||||||
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
|
|
||||||
"anthropic/claude-haiku-4.5"
|
|
||||||
);
|
|
||||||
|
|
||||||
// opus 模型
|
|
||||||
assert_eq!(
|
|
||||||
get_model_from_provider("claude-opus-4-5", &provider, &body),
|
|
||||||
"anthropic/claude-opus-4.5"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_anthropic_to_openai_model_mapping() {
|
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"model": "claude-sonnet-4-5-20250929",
|
"model": "gpt-4o",
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
assert_eq!(result["model"], "gpt-4o");
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_thinking_parameter_detection() {
|
|
||||||
let mut provider = create_openrouter_provider();
|
|
||||||
// 添加推理模型配置
|
|
||||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
|
||||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"thinking": {"type": "enabled"},
|
|
||||||
"messages": [{"role": "user", "content": "Solve this problem"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
// 应该使用推理模型
|
|
||||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_thinking_parameter_disabled() {
|
|
||||||
let mut provider = create_openrouter_provider();
|
|
||||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
|
||||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"thinking": {"type": "disabled"},
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
// 应该使用普通模型
|
|
||||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ use axum::response::{IntoResponse, Response};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::stream::{Stream, StreamExt};
|
use futures::stream::{Stream, StreamExt};
|
||||||
use reqwest::header::HeaderMap;
|
use reqwest::header::HeaderMap;
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::{
|
use std::{
|
||||||
str::FromStr,
|
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
@@ -128,7 +126,15 @@ pub async fn handle_non_streaming(
|
|||||||
ctx.request_model.clone()
|
ctx.request_model.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
|
spawn_log_usage(
|
||||||
|
state,
|
||||||
|
ctx,
|
||||||
|
usage,
|
||||||
|
&model,
|
||||||
|
&ctx.request_model,
|
||||||
|
status.as_u16(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
let model = json_value
|
let model = json_value
|
||||||
.get("model")
|
.get("model")
|
||||||
@@ -140,6 +146,7 @@ pub async fn handle_non_streaming(
|
|||||||
ctx,
|
ctx,
|
||||||
TokenUsage::default(),
|
TokenUsage::default(),
|
||||||
&model,
|
&model,
|
||||||
|
&ctx.request_model,
|
||||||
status.as_u16(),
|
status.as_u16(),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -159,6 +166,7 @@ pub async fn handle_non_streaming(
|
|||||||
ctx,
|
ctx,
|
||||||
TokenUsage::default(),
|
TokenUsage::default(),
|
||||||
&ctx.request_model,
|
&ctx.request_model,
|
||||||
|
&ctx.request_model,
|
||||||
status.as_u16(),
|
status.as_u16(),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -293,6 +301,7 @@ fn create_usage_collector(
|
|||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let provider_id = provider_id.clone();
|
let provider_id = provider_id.clone();
|
||||||
let session_id = session_id.clone();
|
let session_id = session_id.clone();
|
||||||
|
let request_model = request_model.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
log_usage_internal(
|
log_usage_internal(
|
||||||
@@ -300,6 +309,7 @@ fn create_usage_collector(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
app_type_str,
|
app_type_str,
|
||||||
&model,
|
&model,
|
||||||
|
&request_model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
first_token_ms,
|
first_token_ms,
|
||||||
@@ -315,6 +325,7 @@ fn create_usage_collector(
|
|||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let provider_id = provider_id.clone();
|
let provider_id = provider_id.clone();
|
||||||
let session_id = session_id.clone();
|
let session_id = session_id.clone();
|
||||||
|
let request_model = request_model.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
log_usage_internal(
|
log_usage_internal(
|
||||||
@@ -322,6 +333,7 @@ fn create_usage_collector(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
app_type_str,
|
app_type_str,
|
||||||
&model,
|
&model,
|
||||||
|
&request_model,
|
||||||
TokenUsage::default(),
|
TokenUsage::default(),
|
||||||
latency_ms,
|
latency_ms,
|
||||||
first_token_ms,
|
first_token_ms,
|
||||||
@@ -342,6 +354,7 @@ fn spawn_log_usage(
|
|||||||
ctx: &RequestContext,
|
ctx: &RequestContext,
|
||||||
usage: TokenUsage,
|
usage: TokenUsage,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
request_model: &str,
|
||||||
status_code: u16,
|
status_code: u16,
|
||||||
is_streaming: bool,
|
is_streaming: bool,
|
||||||
) {
|
) {
|
||||||
@@ -349,6 +362,7 @@ fn spawn_log_usage(
|
|||||||
let provider_id = ctx.provider.id.clone();
|
let provider_id = ctx.provider.id.clone();
|
||||||
let app_type_str = ctx.app_type_str.to_string();
|
let app_type_str = ctx.app_type_str.to_string();
|
||||||
let model = model.to_string();
|
let model = model.to_string();
|
||||||
|
let request_model = request_model.to_string();
|
||||||
let latency_ms = ctx.latency_ms();
|
let latency_ms = ctx.latency_ms();
|
||||||
let session_id = ctx.session_id.clone();
|
let session_id = ctx.session_id.clone();
|
||||||
|
|
||||||
@@ -358,6 +372,7 @@ fn spawn_log_usage(
|
|||||||
&provider_id,
|
&provider_id,
|
||||||
&app_type_str,
|
&app_type_str,
|
||||||
&model,
|
&model,
|
||||||
|
&request_model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
None,
|
None,
|
||||||
@@ -376,6 +391,7 @@ async fn log_usage_internal(
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
request_model: &str,
|
||||||
usage: TokenUsage,
|
usage: TokenUsage,
|
||||||
latency_ms: u64,
|
latency_ms: u64,
|
||||||
first_token_ms: Option<u64>,
|
first_token_ms: Option<u64>,
|
||||||
@@ -386,26 +402,12 @@ async fn log_usage_internal(
|
|||||||
use super::usage::logger::UsageLogger;
|
use super::usage::logger::UsageLogger;
|
||||||
|
|
||||||
let logger = UsageLogger::new(&state.db);
|
let logger = UsageLogger::new(&state.db);
|
||||||
|
let (multiplier, pricing_model_source) =
|
||||||
// 获取 provider 的 cost_multiplier
|
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||||
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
let pricing_model = if pricing_model_source == "request" {
|
||||||
Ok(Some(p)) => {
|
request_model
|
||||||
if let Some(meta) = p.meta {
|
} else {
|
||||||
if let Some(cm) = meta.cost_multiplier {
|
model
|
||||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
|
||||||
log::warn!(
|
|
||||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
|
||||||
);
|
|
||||||
Decimal::from(1)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Decimal::from(1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Decimal::from(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Decimal::from(1),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let request_id = uuid::Uuid::new_v4().to_string();
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
@@ -424,6 +426,8 @@ async fn log_usage_internal(
|
|||||||
provider_id.to_string(),
|
provider_id.to_string(),
|
||||||
app_type.to_string(),
|
app_type.to_string(),
|
||||||
model.to_string(),
|
model.to_string(),
|
||||||
|
request_model.to_string(),
|
||||||
|
pricing_model.to_string(),
|
||||||
usage,
|
usage,
|
||||||
multiplier,
|
multiplier,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
@@ -556,3 +560,185 @@ fn format_headers(headers: &HeaderMap) -> String {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::ProviderMeta;
|
||||||
|
use crate::proxy::failover_switch::FailoverSwitchManager;
|
||||||
|
use crate::proxy::provider_router::ProviderRouter;
|
||||||
|
use crate::proxy::types::{ProxyConfig, ProxyStatus};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
fn build_state(db: Arc<Database>) -> ProxyState {
|
||||||
|
ProxyState {
|
||||||
|
db: db.clone(),
|
||||||
|
config: Arc::new(RwLock::new(ProxyConfig::default())),
|
||||||
|
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||||
|
start_time: Arc::new(RwLock::new(None)),
|
||||||
|
current_providers: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
provider_router: Arc::new(ProviderRouter::new(db.clone())),
|
||||||
|
app_handle: None,
|
||||||
|
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_pricing(db: &Database) -> Result<(), AppError> {
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
rusqlite::params!["resp-model", "Resp Model", "1.0", "0"],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
rusqlite::params!["req-model", "Req Model", "2.0", "0"],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_provider(
|
||||||
|
db: &Database,
|
||||||
|
id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
meta: ProviderMeta,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let meta_json =
|
||||||
|
serde_json::to_string(&meta).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
rusqlite::params![id, app_type, "Test Provider", "{}", meta_json],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_log_usage_uses_provider_override_config() -> Result<(), AppError> {
|
||||||
|
let db = Arc::new(Database::memory()?);
|
||||||
|
let app_type = "claude";
|
||||||
|
|
||||||
|
db.set_default_cost_multiplier(app_type, "1.5").await?;
|
||||||
|
db.set_pricing_model_source(app_type, "response").await?;
|
||||||
|
seed_pricing(&db)?;
|
||||||
|
|
||||||
|
let mut meta = ProviderMeta::default();
|
||||||
|
meta.cost_multiplier = Some("2".to_string());
|
||||||
|
meta.pricing_model_source = Some("request".to_string());
|
||||||
|
insert_provider(&db, "provider-1", app_type, meta)?;
|
||||||
|
|
||||||
|
let state = build_state(db.clone());
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1_000_000,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
log_usage_internal(
|
||||||
|
&state,
|
||||||
|
"provider-1",
|
||||||
|
app_type,
|
||||||
|
"resp-model",
|
||||||
|
"req-model",
|
||||||
|
usage,
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
200,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let (model, request_model, total_cost, cost_multiplier): (String, String, String, String) =
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT model, request_model, total_cost_usd, cost_multiplier
|
||||||
|
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||||
|
["provider-1"],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
assert_eq!(model, "resp-model");
|
||||||
|
assert_eq!(request_model, "req-model");
|
||||||
|
assert_eq!(
|
||||||
|
Decimal::from_str(&cost_multiplier).unwrap(),
|
||||||
|
Decimal::from_str("2").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Decimal::from_str(&total_cost).unwrap(),
|
||||||
|
Decimal::from_str("4").unwrap()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
|
||||||
|
let db = Arc::new(Database::memory()?);
|
||||||
|
let app_type = "claude";
|
||||||
|
|
||||||
|
db.set_default_cost_multiplier(app_type, "1.5").await?;
|
||||||
|
db.set_pricing_model_source(app_type, "response").await?;
|
||||||
|
seed_pricing(&db)?;
|
||||||
|
|
||||||
|
let meta = ProviderMeta::default();
|
||||||
|
insert_provider(&db, "provider-2", app_type, meta)?;
|
||||||
|
|
||||||
|
let state = build_state(db.clone());
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1_000_000,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
log_usage_internal(
|
||||||
|
&state,
|
||||||
|
"provider-2",
|
||||||
|
app_type,
|
||||||
|
"resp-model",
|
||||||
|
"req-model",
|
||||||
|
usage,
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
200,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let (total_cost, cost_multiplier): (String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT total_cost_usd, cost_multiplier
|
||||||
|
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||||
|
["provider-2"],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Decimal::from_str(&cost_multiplier).unwrap(),
|
||||||
|
Decimal::from_str("1.5").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Decimal::from_str(&total_cost).unwrap(),
|
||||||
|
Decimal::from_str("1.5").unwrap()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ impl ProxyServer {
|
|||||||
|
|
||||||
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
|
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
|
||||||
|
|
||||||
|
// 更新全局代理端口,用于系统代理检测
|
||||||
|
crate::proxy::http_client::set_proxy_port(self.config.listen_port);
|
||||||
|
|
||||||
// 保存关闭句柄
|
// 保存关闭句柄
|
||||||
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -181,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) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod config;
|
|||||||
pub mod env_checker;
|
pub mod env_checker;
|
||||||
pub mod env_manager;
|
pub mod env_manager;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod omo;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
@@ -12,6 +13,7 @@ pub mod usage_stats;
|
|||||||
|
|
||||||
pub use config::ConfigService;
|
pub use config::ConfigService;
|
||||||
pub use mcp::McpService;
|
pub use mcp::McpService;
|
||||||
|
pub use omo::OmoService;
|
||||||
pub use prompt::PromptService;
|
pub use prompt::PromptService;
|
||||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||||
pub use proxy::ProxyService;
|
pub use proxy::ProxyService;
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
use crate::config::write_json_file;
|
||||||
|
use crate::database::OmoGlobalConfig;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::opencode_config::get_opencode_dir;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OmoLocalFileData {
|
||||||
|
pub agents: Option<Value>,
|
||||||
|
pub categories: Option<Value>,
|
||||||
|
pub other_fields: Option<Value>,
|
||||||
|
pub global: OmoGlobalConfig,
|
||||||
|
pub file_path: String,
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
|
||||||
|
|
||||||
|
pub struct OmoService;
|
||||||
|
|
||||||
|
impl OmoService {
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
get_opencode_dir().join("oh-my-opencode.jsonc")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_local_config_path() -> Result<PathBuf, AppError> {
|
||||||
|
let config_path = Self::config_path();
|
||||||
|
if config_path.exists() {
|
||||||
|
return Ok(config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let json_path = config_path.with_extension("json");
|
||||||
|
if json_path.exists() {
|
||||||
|
return Ok(json_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::OmoConfigNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
|
||||||
|
let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
||||||
|
let cleaned = Self::strip_jsonc_comments(&content);
|
||||||
|
let parsed: Value = serde_json::from_str(&cleaned)
|
||||||
|
.map_err(|e| AppError::Config(format!("Failed to parse oh-my-opencode config: {e}")))?;
|
||||||
|
parsed
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_other_fields(obj: &Map<String, Value>) -> Map<String, Value> {
|
||||||
|
const KNOWN_KEYS: [&str; 13] = [
|
||||||
|
"$schema",
|
||||||
|
"agents",
|
||||||
|
"categories",
|
||||||
|
"sisyphus_agent",
|
||||||
|
"disabled_agents",
|
||||||
|
"disabled_mcps",
|
||||||
|
"disabled_hooks",
|
||||||
|
"disabled_skills",
|
||||||
|
"lsp",
|
||||||
|
"experimental",
|
||||||
|
"background_task",
|
||||||
|
"browser_automation_engine",
|
||||||
|
"claude_code",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut other = Map::new();
|
||||||
|
for (k, v) in obj {
|
||||||
|
if !KNOWN_KEYS.contains(&k.as_str()) {
|
||||||
|
other.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_string_array(val: &Value) -> Vec<String> {
|
||||||
|
val.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_global_from_obj(obj: &Map<String, Value>, global: &mut OmoGlobalConfig) {
|
||||||
|
if let Some(v) = obj.get("$schema") {
|
||||||
|
global.schema_url = v.as_str().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
for (key, target) in [
|
||||||
|
("disabled_agents", &mut global.disabled_agents),
|
||||||
|
("disabled_mcps", &mut global.disabled_mcps),
|
||||||
|
("disabled_hooks", &mut global.disabled_hooks),
|
||||||
|
("disabled_skills", &mut global.disabled_skills),
|
||||||
|
] {
|
||||||
|
if let Some(v) = obj.get(key) {
|
||||||
|
*target = Self::extract_string_array(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, target) in [
|
||||||
|
("sisyphus_agent", &mut global.sisyphus_agent),
|
||||||
|
("lsp", &mut global.lsp),
|
||||||
|
("experimental", &mut global.experimental),
|
||||||
|
("background_task", &mut global.background_task),
|
||||||
|
(
|
||||||
|
"browser_automation_engine",
|
||||||
|
&mut global.browser_automation_engine,
|
||||||
|
),
|
||||||
|
("claude_code", &mut global.claude_code),
|
||||||
|
] {
|
||||||
|
if let Some(v) = obj.get(key) {
|
||||||
|
*target = Some(v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
|
||||||
|
if let Some(v) = value {
|
||||||
|
result.insert(key.to_string(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_string_array(result: &mut Map<String, Value>, key: &str, values: &[String]) {
|
||||||
|
if !values.is_empty() {
|
||||||
|
result.insert(
|
||||||
|
key.to_string(),
|
||||||
|
serde_json::to_value(values).unwrap_or(Value::Array(vec![])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {
|
||||||
|
if let Some(Value::Object(map)) = value {
|
||||||
|
for (k, v) in map {
|
||||||
|
result.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_config_file() -> Result<(), AppError> {
|
||||||
|
let config_path = Self::config_path();
|
||||||
|
if config_path.exists() {
|
||||||
|
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
log::info!("OMO config file deleted: {config_path:?}");
|
||||||
|
}
|
||||||
|
crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> {
|
||||||
|
let global = state.db.get_omo_global_config()?;
|
||||||
|
let current_omo = state.db.get_current_omo_provider("opencode")?;
|
||||||
|
|
||||||
|
let profile_data = current_omo.as_ref().map(|p| {
|
||||||
|
let agents = p.settings_config.get("agents").cloned();
|
||||||
|
let categories = p.settings_config.get("categories").cloned();
|
||||||
|
let other_fields = p.settings_config.get("otherFields").cloned();
|
||||||
|
let use_common_config = p
|
||||||
|
.settings_config
|
||||||
|
.get("useCommonConfig")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
(agents, categories, other_fields, use_common_config)
|
||||||
|
});
|
||||||
|
|
||||||
|
let merged = Self::merge_config(&global, profile_data.as_ref());
|
||||||
|
let config_path = Self::config_path();
|
||||||
|
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_file(&config_path, &merged)?;
|
||||||
|
|
||||||
|
crate::opencode_config::add_plugin("oh-my-opencode@latest")?;
|
||||||
|
|
||||||
|
log::info!("OMO config written to {config_path:?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value {
|
||||||
|
let mut result = Map::new();
|
||||||
|
let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true);
|
||||||
|
|
||||||
|
if use_common_config {
|
||||||
|
if let Some(url) = &global.schema_url {
|
||||||
|
result.insert("$schema".to_string(), Value::String(url.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::insert_opt_value(&mut result, "sisyphus_agent", &global.sisyphus_agent);
|
||||||
|
Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents);
|
||||||
|
Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps);
|
||||||
|
Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks);
|
||||||
|
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
|
||||||
|
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
|
||||||
|
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
|
||||||
|
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
|
||||||
|
Self::insert_opt_value(
|
||||||
|
&mut result,
|
||||||
|
"browser_automation_engine",
|
||||||
|
&global.browser_automation_engine,
|
||||||
|
);
|
||||||
|
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
|
||||||
|
|
||||||
|
Self::insert_object_entries(&mut result, global.other_fields.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((agents, categories, other_fields, _)) = profile_data {
|
||||||
|
Self::insert_opt_value(&mut result, "agents", agents);
|
||||||
|
Self::insert_opt_value(&mut result, "categories", categories);
|
||||||
|
Self::insert_object_entries(&mut result, other_fields.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
Value::Object(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_from_local(state: &AppState) -> Result<crate::provider::Provider, AppError> {
|
||||||
|
let actual_path = Self::resolve_local_config_path()?;
|
||||||
|
Self::import_from_path(state, &actual_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_from_path(
|
||||||
|
state: &AppState,
|
||||||
|
path: &std::path::Path,
|
||||||
|
) -> Result<crate::provider::Provider, AppError> {
|
||||||
|
let obj = Self::read_jsonc_object(path)?;
|
||||||
|
|
||||||
|
let mut settings = Map::new();
|
||||||
|
if let Some(agents) = obj.get("agents") {
|
||||||
|
settings.insert("agents".to_string(), agents.clone());
|
||||||
|
}
|
||||||
|
if let Some(categories) = obj.get("categories") {
|
||||||
|
settings.insert("categories".to_string(), categories.clone());
|
||||||
|
}
|
||||||
|
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
|
||||||
|
|
||||||
|
let other = Self::extract_other_fields(&obj);
|
||||||
|
if !other.is_empty() {
|
||||||
|
settings.insert("otherFields".to_string(), Value::Object(other));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut global = state.db.get_omo_global_config()?;
|
||||||
|
Self::merge_global_from_obj(&obj, &mut global);
|
||||||
|
global.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
state.db.save_omo_global_config(&global)?;
|
||||||
|
|
||||||
|
let provider_id = format!("omo-{}", uuid::Uuid::new_v4());
|
||||||
|
let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M"));
|
||||||
|
let settings_config =
|
||||||
|
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
|
||||||
|
let provider = crate::provider::Provider {
|
||||||
|
id: provider_id,
|
||||||
|
name,
|
||||||
|
settings_config,
|
||||||
|
website_url: None,
|
||||||
|
category: Some("omo".to_string()),
|
||||||
|
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.db.save_provider("opencode", &provider)?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_omo_provider_current("opencode", &provider.id)?;
|
||||||
|
Self::write_config_to_file(state)?;
|
||||||
|
Ok(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_local_file() -> Result<OmoLocalFileData, AppError> {
|
||||||
|
let actual_path = Self::resolve_local_config_path()?;
|
||||||
|
let metadata = std::fs::metadata(&actual_path).ok();
|
||||||
|
let last_modified = metadata
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339());
|
||||||
|
|
||||||
|
let obj = Self::read_jsonc_object(&actual_path)?;
|
||||||
|
|
||||||
|
let agents = obj.get("agents").cloned();
|
||||||
|
let categories = obj.get("categories").cloned();
|
||||||
|
|
||||||
|
let other = Self::extract_other_fields(&obj);
|
||||||
|
let other_fields = if other.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Value::Object(other))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut global = OmoGlobalConfig::default();
|
||||||
|
Self::merge_global_from_obj(&obj, &mut global);
|
||||||
|
|
||||||
|
Ok(OmoLocalFileData {
|
||||||
|
agents,
|
||||||
|
categories,
|
||||||
|
other_fields,
|
||||||
|
global,
|
||||||
|
file_path: actual_path.to_string_lossy().to_string(),
|
||||||
|
last_modified,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_jsonc_comments(input: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(input.len());
|
||||||
|
let mut chars = input.chars().peekable();
|
||||||
|
let mut in_string = false;
|
||||||
|
let mut escape = false;
|
||||||
|
|
||||||
|
while let Some(&c) = chars.peek() {
|
||||||
|
if in_string {
|
||||||
|
result.push(c);
|
||||||
|
chars.next();
|
||||||
|
if escape {
|
||||||
|
escape = false;
|
||||||
|
} else if c == '\\' {
|
||||||
|
escape = true;
|
||||||
|
} else if c == '"' {
|
||||||
|
in_string = false;
|
||||||
|
}
|
||||||
|
} else if c == '"' {
|
||||||
|
in_string = true;
|
||||||
|
result.push(c);
|
||||||
|
chars.next();
|
||||||
|
} else if c == '/' {
|
||||||
|
chars.next();
|
||||||
|
match chars.peek() {
|
||||||
|
Some('/') => {
|
||||||
|
chars.next();
|
||||||
|
while let Some(&nc) = chars.peek() {
|
||||||
|
if nc == '\n' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some('*') => {
|
||||||
|
chars.next();
|
||||||
|
while let Some(nc) = chars.next() {
|
||||||
|
if nc == '*' {
|
||||||
|
if let Some(&'/') = chars.peek() {
|
||||||
|
chars.next();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
result.push('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(c);
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_jsonc_comments() {
|
||||||
|
let input = r#"{
|
||||||
|
// This is a comment
|
||||||
|
"key": "value", // inline comment
|
||||||
|
/* multi
|
||||||
|
line */
|
||||||
|
"key2": "val//ue"
|
||||||
|
}"#;
|
||||||
|
let result = OmoService::strip_jsonc_comments(input);
|
||||||
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||||
|
assert_eq!(parsed["key"], "value");
|
||||||
|
assert_eq!(parsed["key2"], "val//ue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_config_empty() {
|
||||||
|
let global = OmoGlobalConfig::default();
|
||||||
|
let merged = OmoService::merge_config(&global, None);
|
||||||
|
assert!(merged.is_object());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_config_with_profile() {
|
||||||
|
let global = OmoGlobalConfig {
|
||||||
|
schema_url: Some("https://example.com/schema.json".to_string()),
|
||||||
|
disabled_agents: vec!["explore".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let agents = Some(serde_json::json!({
|
||||||
|
"Sisyphus": { "model": "claude-opus-4-5" }
|
||||||
|
}));
|
||||||
|
let categories = None;
|
||||||
|
let other_fields = None;
|
||||||
|
let profile_data = (agents, categories, other_fields, true);
|
||||||
|
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
||||||
|
let obj = merged.as_object().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
||||||
|
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
|
||||||
|
assert!(obj.contains_key("agents"));
|
||||||
|
assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_config_without_common_config() {
|
||||||
|
let global = OmoGlobalConfig {
|
||||||
|
schema_url: Some("https://example.com/schema.json".to_string()),
|
||||||
|
disabled_agents: vec!["explore".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let agents = Some(serde_json::json!({
|
||||||
|
"Sisyphus": { "model": "claude-opus-4-5" }
|
||||||
|
}));
|
||||||
|
let categories = None;
|
||||||
|
let other_fields = None;
|
||||||
|
let profile_data = (agents, categories, other_fields, false);
|
||||||
|
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
||||||
|
let obj = merged.as_object().unwrap();
|
||||||
|
|
||||||
|
assert!(!obj.contains_key("$schema"));
|
||||||
|
assert!(!obj.contains_key("disabled_agents"));
|
||||||
|
assert!(obj.contains_key("agents"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -182,33 +195,67 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
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
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub use 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
|
||||||
@@ -163,6 +164,12 @@ impl ProviderService {
|
|||||||
|
|
||||||
// OpenCode uses additive mode - always write to live config
|
// OpenCode uses additive mode - always write to live config
|
||||||
if matches!(app_type, AppType::OpenCode) {
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
// OMO providers use exclusive mode and write to dedicated config file.
|
||||||
|
if provider.category.as_deref() == Some("omo") {
|
||||||
|
// Do not auto-enable newly added OMO providers.
|
||||||
|
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
write_live_snapshot(&app_type, &provider)?;
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -196,6 +203,15 @@ impl ProviderService {
|
|||||||
|
|
||||||
// OpenCode uses additive mode - always update in live config
|
// OpenCode uses additive mode - always update in live config
|
||||||
if matches!(app_type, AppType::OpenCode) {
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
if provider.category.as_deref() == Some("omo") {
|
||||||
|
let is_omo_current = state
|
||||||
|
.db
|
||||||
|
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
|
||||||
|
if is_omo_current {
|
||||||
|
crate::services::OmoService::write_config_to_file(state)?;
|
||||||
|
}
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
write_live_snapshot(&app_type, &provider)?;
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -241,6 +257,35 @@ impl ProviderService {
|
|||||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
// OpenCode uses additive mode - no current provider concept
|
// OpenCode uses additive mode - no current provider concept
|
||||||
if matches!(app_type, AppType::OpenCode) {
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
let is_omo = state
|
||||||
|
.db
|
||||||
|
.get_provider_by_id(id, app_type.as_str())?
|
||||||
|
.and_then(|p| p.category)
|
||||||
|
.as_deref()
|
||||||
|
== Some("omo");
|
||||||
|
|
||||||
|
if is_omo {
|
||||||
|
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
||||||
|
let omo_count = state
|
||||||
|
.db
|
||||||
|
.get_all_providers(app_type.as_str())?
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if omo_count <= 1 && was_current {
|
||||||
|
return Err(AppError::Message(
|
||||||
|
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.db.delete_provider(app_type.as_str(), id)?;
|
||||||
|
if was_current {
|
||||||
|
crate::services::OmoService::delete_config_file()?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Remove from database
|
// Remove from database
|
||||||
state.db.delete_provider(app_type.as_str(), id)?;
|
state.db.delete_provider(app_type.as_str(), id)?;
|
||||||
// Also remove from live config
|
// Also remove from live config
|
||||||
@@ -266,10 +311,32 @@ impl ProviderService {
|
|||||||
/// Does NOT delete from database - provider remains in the list.
|
/// Does NOT delete from database - provider remains in the list.
|
||||||
/// This is used when user wants to "remove" a provider from active config
|
/// This is used when user wants to "remove" a provider from active config
|
||||||
/// but keep it available for future use.
|
/// but keep it available for future use.
|
||||||
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
|
pub fn remove_from_live_config(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: AppType,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
match app_type {
|
match app_type {
|
||||||
AppType::OpenCode => {
|
AppType::OpenCode => {
|
||||||
remove_opencode_provider_from_live(id)?;
|
let is_omo = state
|
||||||
|
.db
|
||||||
|
.get_provider_by_id(id, app_type.as_str())?
|
||||||
|
.and_then(|p| p.category)
|
||||||
|
.as_deref()
|
||||||
|
== Some("omo");
|
||||||
|
|
||||||
|
if is_omo {
|
||||||
|
state.db.clear_omo_provider_current(app_type.as_str(), id)?;
|
||||||
|
let still_has_current =
|
||||||
|
state.db.get_current_omo_provider("opencode")?.is_some();
|
||||||
|
if still_has_current {
|
||||||
|
crate::services::OmoService::write_config_to_file(state)?;
|
||||||
|
} else {
|
||||||
|
crate::services::OmoService::delete_config_file()?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remove_opencode_provider_from_live(id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Future: add other additive mode apps here
|
// Future: add other additive mode apps here
|
||||||
_ => {
|
_ => {
|
||||||
@@ -301,6 +368,11 @@ impl ProviderService {
|
|||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||||
|
|
||||||
|
// OMO providers are switched through their own exclusive path.
|
||||||
|
if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") {
|
||||||
|
return Self::switch_normal(state, app_type, id, &providers);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if proxy takeover mode is active AND proxy server is actually running
|
// Check if proxy takeover mode is active AND proxy server is actually running
|
||||||
// Both conditions must be true to use hot-switch mode
|
// Both conditions must be true to use hot-switch mode
|
||||||
// Use blocking wait since this is a sync function
|
// Use blocking wait since this is a sync function
|
||||||
@@ -372,25 +444,28 @@ impl ProviderService {
|
|||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||||
|
|
||||||
|
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
|
||||||
|
state.db.set_omo_provider_current(app_type.as_str(), id)?;
|
||||||
|
crate::services::OmoService::write_config_to_file(state)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Backfill: Backfill current live config to current provider
|
// Backfill: Backfill current live config to current provider
|
||||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||||
|
|
||||||
if let Some(current_id) = current_id {
|
match (current_id, matches!(app_type, AppType::OpenCode)) {
|
||||||
if current_id != id {
|
(Some(current_id), false) if current_id != id => {
|
||||||
// OpenCode uses additive mode - all providers coexist in the same file,
|
// Only backfill when switching to a different provider.
|
||||||
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||||
if !matches!(app_type, AppType::OpenCode) {
|
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||||
// Only backfill when switching to a different provider
|
current_provider.settings_config = live_config;
|
||||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
// Ignore backfill failure, don't affect switch flow.
|
||||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||||
|
|||||||
@@ -1654,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> {
|
||||||
|
|||||||
@@ -252,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);
|
||||||
|
|
||||||
// 如果已存在则跳过下载
|
// 如果已存在则跳过下载
|
||||||
@@ -933,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);
|
||||||
@@ -1064,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(())
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 仓库管理(保留原有逻辑)==========
|
// ========== 仓库管理(保留原有逻辑)==========
|
||||||
|
|
||||||
/// 列出仓库
|
/// 列出仓库
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -303,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(
|
||||||
@@ -373,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 格式)
|
||||||
@@ -399,40 +409,50 @@ 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 流式检查
|
||||||
@@ -689,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('"', "\\\"")
|
||||||
|
}
|
||||||
@@ -79,6 +79,9 @@ pub struct AppSettings {
|
|||||||
/// 是否开机自启
|
/// 是否开机自启
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub launch_on_startup: bool,
|
pub launch_on_startup: bool,
|
||||||
|
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
||||||
|
#[serde(default)]
|
||||||
|
pub silent_startup: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
|
||||||
@@ -114,6 +117,14 @@ pub struct AppSettings {
|
|||||||
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skill_sync_method: SyncMethod,
|
pub skill_sync_method: SyncMethod,
|
||||||
|
|
||||||
|
// ===== 终端设置 =====
|
||||||
|
/// 首选终端应用(可选,默认使用系统默认终端)
|
||||||
|
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||||
|
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
|
||||||
|
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub preferred_terminal: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_show_in_tray() -> bool {
|
fn default_show_in_tray() -> bool {
|
||||||
@@ -132,6 +143,7 @@ impl Default for AppSettings {
|
|||||||
enable_claude_plugin_integration: false,
|
enable_claude_plugin_integration: false,
|
||||||
skip_claude_onboarding: false,
|
skip_claude_onboarding: false,
|
||||||
launch_on_startup: false,
|
launch_on_startup: false,
|
||||||
|
silent_startup: false,
|
||||||
language: None,
|
language: None,
|
||||||
visible_apps: None,
|
visible_apps: None,
|
||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
@@ -143,6 +155,7 @@ impl Default for AppSettings {
|
|||||||
current_provider_gemini: None,
|
current_provider_gemini: None,
|
||||||
current_provider_opencode: None,
|
current_provider_opencode: None,
|
||||||
skill_sync_method: SyncMethod::default(),
|
skill_sync_method: SyncMethod::default(),
|
||||||
|
preferred_terminal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +163,11 @@ impl Default for AppSettings {
|
|||||||
impl AppSettings {
|
impl AppSettings {
|
||||||
fn settings_path() -> Option<PathBuf> {
|
fn settings_path() -> Option<PathBuf> {
|
||||||
// settings.json 保留用于旧版本迁移和无数据库场景
|
// settings.json 保留用于旧版本迁移和无数据库场景
|
||||||
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
|
Some(
|
||||||
|
crate::config::get_home_dir()
|
||||||
|
.join(".cc-switch")
|
||||||
|
.join("settings.json"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_paths(&mut self) {
|
fn normalize_paths(&mut self) {
|
||||||
@@ -402,3 +419,17 @@ pub fn get_skill_sync_method() -> SyncMethod {
|
|||||||
})
|
})
|
||||||
.skill_sync_method
|
.skill_sync_method
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 终端设置管理函数 =====
|
||||||
|
|
||||||
|
/// 获取首选终端应用
|
||||||
|
pub fn get_preferred_terminal() -> Option<String> {
|
||||||
|
settings_store()
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||||
|
e.into_inner()
|
||||||
|
})
|
||||||
|
.preferred_terminal
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
|||||||
prefix: "claude_",
|
prefix: "claude_",
|
||||||
header_id: "claude_header",
|
header_id: "claude_header",
|
||||||
empty_id: "claude_empty",
|
empty_id: "claude_empty",
|
||||||
header_label: "─── Claude ───",
|
header_label: "Claude",
|
||||||
log_name: "Claude",
|
log_name: "Claude",
|
||||||
},
|
},
|
||||||
TrayAppSection {
|
TrayAppSection {
|
||||||
@@ -71,7 +71,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
|||||||
prefix: "codex_",
|
prefix: "codex_",
|
||||||
header_id: "codex_header",
|
header_id: "codex_header",
|
||||||
empty_id: "codex_empty",
|
empty_id: "codex_empty",
|
||||||
header_label: "─── Codex ───",
|
header_label: "Codex",
|
||||||
log_name: "Codex",
|
log_name: "Codex",
|
||||||
},
|
},
|
||||||
TrayAppSection {
|
TrayAppSection {
|
||||||
@@ -79,7 +79,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
|||||||
prefix: "gemini_",
|
prefix: "gemini_",
|
||||||
header_id: "gemini_header",
|
header_id: "gemini_header",
|
||||||
empty_id: "gemini_empty",
|
empty_id: "gemini_empty",
|
||||||
header_label: "─── Gemini ───",
|
header_label: "Gemini",
|
||||||
log_name: "Gemini",
|
log_name: "Gemini",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -391,13 +391,16 @@ pub fn create_tray_menu(
|
|||||||
&tray_texts,
|
&tray_texts,
|
||||||
app_state,
|
app_state,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// 在每个 section 后添加分隔符
|
||||||
|
menu_builder = menu_builder.separator();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分隔符和退出菜单
|
// 退出菜单(分隔符已在上面的 section 循环中添加)
|
||||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||||
|
|
||||||
menu_builder = menu_builder.separator().item(&quit_item);
|
menu_builder = menu_builder.item(&quit_item);
|
||||||
|
|
||||||
menu_builder
|
menu_builder
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.10.2",
|
"version": "3.10.3",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"height": 650,
|
"height": 650,
|
||||||
"minWidth": 900,
|
"minWidth": 900,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
|
"visible": false,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"center": true
|
"center": true
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "CC Switch",
|
"title": "CC Switch",
|
||||||
"titleBarStyle": "Visible",
|
"titleBarStyle": "Visible",
|
||||||
|
"visible": false,
|
||||||
"minWidth": 900,
|
"minWidth": 900,
|
||||||
"minHeight": 600
|
"minHeight": 600
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -971,12 +971,18 @@ fn export_sql_returns_error_for_invalid_path() {
|
|||||||
|
|
||||||
let state = create_test_state().expect("create test state");
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
// Try to export to an invalid path (parent directory doesn't exist)
|
// Try to export to an invalid path (nonexistent parent or invalid name on Windows)
|
||||||
let invalid_path = PathBuf::from("/nonexistent/directory/export.sql");
|
let invalid_parent = if cfg!(windows) {
|
||||||
|
std::env::temp_dir().join("cc-switch-test-invalid<>dir")
|
||||||
|
} else {
|
||||||
|
PathBuf::from("/nonexistent/directory")
|
||||||
|
};
|
||||||
|
let invalid_path = invalid_parent.join("export.sql");
|
||||||
let err = state
|
let err = state
|
||||||
.db
|
.db
|
||||||
.export_sql(&invalid_path)
|
.export_sql(&invalid_path)
|
||||||
.expect_err("export to invalid path should fail");
|
.expect_err("export to invalid path should fail");
|
||||||
|
let invalid_prefix = invalid_parent.to_string_lossy();
|
||||||
|
|
||||||
// The error can be either IoContext or Io depending on where it fails
|
// The error can be either IoContext or Io depending on where it fails
|
||||||
match err {
|
match err {
|
||||||
@@ -988,8 +994,8 @@ fn export_sql_returns_error_for_invalid_path() {
|
|||||||
}
|
}
|
||||||
AppError::Io { path, .. } => {
|
AppError::Io { path, .. } => {
|
||||||
assert!(
|
assert!(
|
||||||
path.starts_with("/nonexistent"),
|
path.starts_with(invalid_prefix.as_ref()),
|
||||||
"expected error for /nonexistent path, got: {path:?}"
|
"expected error for {invalid_parent:?}, got: {path:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
other => panic!("expected IoContext or Io error, got {other:?}"),
|
other => panic!("expected IoContext or Io error, got {other:?}"),
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use cc_switch_lib::{
|
||||||
|
get_default_cost_multiplier_test_hook, get_pricing_model_source_test_hook,
|
||||||
|
set_default_cost_multiplier_test_hook, set_pricing_model_source_test_hook, AppError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为
|
||||||
|
#[allow(clippy::await_holding_lock)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_cost_multiplier_commands_round_trip() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
|
let default = get_default_cost_multiplier_test_hook(&state, "claude")
|
||||||
|
.await
|
||||||
|
.expect("read default multiplier");
|
||||||
|
assert_eq!(default, "1");
|
||||||
|
|
||||||
|
set_default_cost_multiplier_test_hook(&state, "claude", "1.5")
|
||||||
|
.await
|
||||||
|
.expect("set multiplier");
|
||||||
|
let updated = get_default_cost_multiplier_test_hook(&state, "claude")
|
||||||
|
.await
|
||||||
|
.expect("read updated multiplier");
|
||||||
|
assert_eq!(updated, "1.5");
|
||||||
|
|
||||||
|
let err = set_default_cost_multiplier_test_hook(&state, "claude", "not-a-number")
|
||||||
|
.await
|
||||||
|
.expect_err("invalid multiplier should error");
|
||||||
|
// 错误已改为 Localized 类型(支持 i18n)
|
||||||
|
match err {
|
||||||
|
AppError::Localized { key, .. } => {
|
||||||
|
assert_eq!(key, "error.invalidMultiplier");
|
||||||
|
}
|
||||||
|
other => panic!("expected localized error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为
|
||||||
|
#[allow(clippy::await_holding_lock)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pricing_model_source_commands_round_trip() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
|
let default = get_pricing_model_source_test_hook(&state, "claude")
|
||||||
|
.await
|
||||||
|
.expect("read default pricing model source");
|
||||||
|
assert_eq!(default, "response");
|
||||||
|
|
||||||
|
set_pricing_model_source_test_hook(&state, "claude", "request")
|
||||||
|
.await
|
||||||
|
.expect("set pricing model source");
|
||||||
|
let updated = get_pricing_model_source_test_hook(&state, "claude")
|
||||||
|
.await
|
||||||
|
.expect("read updated pricing model source");
|
||||||
|
assert_eq!(updated, "request");
|
||||||
|
|
||||||
|
let err = set_pricing_model_source_test_hook(&state, "claude", "invalid")
|
||||||
|
.await
|
||||||
|
.expect_err("invalid pricing model source should error");
|
||||||
|
// 错误已改为 Localized 类型(支持 i18n)
|
||||||
|
match err {
|
||||||
|
AppError::Localized { key, .. } => {
|
||||||
|
assert_eq!(key, "error.invalidPricingMode");
|
||||||
|
}
|
||||||
|
other => panic!("expected localized error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ pub fn ensure_test_home() -> &'static Path {
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
std::fs::create_dir_all(&base).expect("create test home");
|
std::fs::create_dir_all(&base).expect("create test home");
|
||||||
|
// Windows 上 `dirs::home_dir()` 不受 HOME/USERPROFILE 影响(走 Known Folder API),
|
||||||
|
// 用 CC_SWITCH_TEST_HOME 显式覆盖,以确保测试不会污染真实用户目录。
|
||||||
|
std::env::set_var("CC_SWITCH_TEST_HOME", &base);
|
||||||
std::env::set_var("HOME", &base);
|
std::env::set_var("HOME", &base);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
std::env::set_var("USERPROFILE", &base);
|
std::env::set_var("USERPROFILE", &base);
|
||||||
|
|||||||
+103
-76
@@ -8,13 +8,14 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
// Bot, // TODO: Agents 功能开发中,暂时不需要
|
|
||||||
Book,
|
Book,
|
||||||
Wrench,
|
Wrench,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
History,
|
||||||
Download,
|
|
||||||
BarChart2,
|
BarChart2,
|
||||||
|
Download,
|
||||||
|
FolderArchive,
|
||||||
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Provider, VisibleApps } from "@/types";
|
import type { Provider, VisibleApps } from "@/types";
|
||||||
import type { EnvConflict } from "@/types/env";
|
import type { EnvConflict } from "@/types/env";
|
||||||
@@ -53,6 +54,8 @@ import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
|||||||
import { UniversalProviderPanel } from "@/components/universal";
|
import { UniversalProviderPanel } from "@/components/universal";
|
||||||
import { McpIcon } from "@/components/BrandIcons";
|
import { McpIcon } from "@/components/BrandIcons";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||||
|
import { useDisableCurrentOmo } from "@/lib/query/omo";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "providers"
|
| "providers"
|
||||||
@@ -62,23 +65,58 @@ type View =
|
|||||||
| "skillsDiscovery"
|
| "skillsDiscovery"
|
||||||
| "mcp"
|
| "mcp"
|
||||||
| "agents"
|
| "agents"
|
||||||
| "universal";
|
| "universal"
|
||||||
|
| "sessions";
|
||||||
|
|
||||||
// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar
|
|
||||||
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
||||||
const HEADER_HEIGHT = 64; // px
|
const HEADER_HEIGHT = 64; // px
|
||||||
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||||
|
|
||||||
|
const STORAGE_KEY = "cc-switch-last-app";
|
||||||
|
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||||
|
|
||||||
|
const getInitialApp = (): AppId => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
|
||||||
|
if (saved && VALID_APPS.includes(saved)) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
return "claude";
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIEW_STORAGE_KEY = "cc-switch-last-view";
|
||||||
|
const VALID_VIEWS: View[] = [
|
||||||
|
"providers",
|
||||||
|
"settings",
|
||||||
|
"prompts",
|
||||||
|
"skills",
|
||||||
|
"skillsDiscovery",
|
||||||
|
"mcp",
|
||||||
|
"agents",
|
||||||
|
"universal",
|
||||||
|
"sessions",
|
||||||
|
];
|
||||||
|
|
||||||
|
const getInitialView = (): View => {
|
||||||
|
const saved = localStorage.getItem(VIEW_STORAGE_KEY) as View | null;
|
||||||
|
if (saved && VALID_VIEWS.includes(saved)) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
return "providers";
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [activeApp, setActiveApp] = useState<AppId>("claude");
|
const [activeApp, setActiveApp] = useState<AppId>(getInitialApp);
|
||||||
const [currentView, setCurrentView] = useState<View>("providers");
|
const [currentView, setCurrentView] = useState<View>(getInitialView);
|
||||||
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
|
|
||||||
// Get settings for visibleApps
|
useEffect(() => {
|
||||||
|
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
|
||||||
|
}, [currentView]);
|
||||||
|
|
||||||
const { data: settingsData } = useSettingsQuery();
|
const { data: settingsData } = useSettingsQuery();
|
||||||
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
|
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
|
||||||
claude: true,
|
claude: true,
|
||||||
@@ -87,7 +125,6 @@ function App() {
|
|||||||
opencode: true,
|
opencode: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get first visible app for fallback
|
|
||||||
const getFirstVisibleApp = (): AppId => {
|
const getFirstVisibleApp = (): AppId => {
|
||||||
if (visibleApps.claude) return "claude";
|
if (visibleApps.claude) return "claude";
|
||||||
if (visibleApps.codex) return "codex";
|
if (visibleApps.codex) return "codex";
|
||||||
@@ -96,7 +133,6 @@ function App() {
|
|||||||
return "claude"; // fallback
|
return "claude"; // fallback
|
||||||
};
|
};
|
||||||
|
|
||||||
// If current active app is hidden, switch to first visible app
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visibleApps[activeApp]) {
|
if (!visibleApps[activeApp]) {
|
||||||
setActiveApp(getFirstVisibleApp());
|
setActiveApp(getFirstVisibleApp());
|
||||||
@@ -105,7 +141,6 @@ function App() {
|
|||||||
|
|
||||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||||
// Confirm action state: 'remove' = remove from live config, 'delete' = delete from database
|
|
||||||
const [confirmAction, setConfirmAction] = useState<{
|
const [confirmAction, setConfirmAction] = useState<{
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
action: "remove" | "delete";
|
action: "remove" | "delete";
|
||||||
@@ -113,7 +148,6 @@ function App() {
|
|||||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||||
|
|
||||||
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
|
|
||||||
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
||||||
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
||||||
|
|
||||||
@@ -124,15 +158,12 @@ function App() {
|
|||||||
const addActionButtonClass =
|
const addActionButtonClass =
|
||||||
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
||||||
|
|
||||||
// 获取代理服务状态
|
|
||||||
const {
|
const {
|
||||||
isRunning: isProxyRunning,
|
isRunning: isProxyRunning,
|
||||||
takeoverStatus,
|
takeoverStatus,
|
||||||
status: proxyStatus,
|
status: proxyStatus,
|
||||||
} = useProxyStatus();
|
} = useProxyStatus();
|
||||||
// 当前应用的代理是否开启
|
|
||||||
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
||||||
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
|
|
||||||
const activeProviderId = useMemo(() => {
|
const activeProviderId = useMemo(() => {
|
||||||
const target = proxyStatus?.active_targets?.find(
|
const target = proxyStatus?.active_targets?.find(
|
||||||
(t) => t.app_type === activeApp,
|
(t) => t.app_type === activeApp,
|
||||||
@@ -140,7 +171,6 @@ function App() {
|
|||||||
return target?.provider_id;
|
return target?.provider_id;
|
||||||
}, [proxyStatus?.active_targets, activeApp]);
|
}, [proxyStatus?.active_targets, activeApp]);
|
||||||
|
|
||||||
// 获取供应商列表,当代理服务运行时自动刷新
|
|
||||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
});
|
});
|
||||||
@@ -148,7 +178,6 @@ function App() {
|
|||||||
const currentProviderId = data?.currentProviderId ?? "";
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
const hasSkillsSupport = true;
|
const hasSkillsSupport = true;
|
||||||
|
|
||||||
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
|
||||||
const {
|
const {
|
||||||
addProvider,
|
addProvider,
|
||||||
updateProvider,
|
updateProvider,
|
||||||
@@ -157,7 +186,23 @@ function App() {
|
|||||||
saveUsageScript,
|
saveUsageScript,
|
||||||
} = useProviderActions(activeApp);
|
} = useProviderActions(activeApp);
|
||||||
|
|
||||||
// 监听来自托盘菜单的切换事件
|
const disableOmoMutation = useDisableCurrentOmo();
|
||||||
|
const handleDisableOmo = () => {
|
||||||
|
disableOmoMutation.mutate(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(
|
||||||
|
t("omo.disableFailed", {
|
||||||
|
defaultValue: "停用 OMO 失败: {{error}}",
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
@@ -181,7 +226,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [activeApp, refetch]);
|
}, [activeApp, refetch]);
|
||||||
|
|
||||||
// 监听统一供应商同步事件,刷新所有应用的供应商列表
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
@@ -189,10 +233,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
unsubscribe = await listen("universal-provider-synced", async () => {
|
unsubscribe = await listen("universal-provider-synced", async () => {
|
||||||
// 统一供应商同步后刷新所有应用的供应商列表
|
|
||||||
// 使用 invalidateQueries 使所有 providers 查询失效
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["providers"] });
|
await queryClient.invalidateQueries({ queryKey: ["providers"] });
|
||||||
// 同时更新托盘菜单
|
|
||||||
try {
|
try {
|
||||||
await providersApi.updateTrayMenu();
|
await providersApi.updateTrayMenu();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -213,7 +254,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
// 应用启动时检测所有应用的环境变量冲突
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkEnvOnStartup = async () => {
|
const checkEnvOnStartup = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -238,7 +278,6 @@ function App() {
|
|||||||
checkEnvOnStartup();
|
checkEnvOnStartup();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 应用启动时检查是否刚完成了配置迁移
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMigration = async () => {
|
const checkMigration = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -257,7 +296,6 @@ function App() {
|
|||||||
checkMigration();
|
checkMigration();
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
// 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkSkillsMigration = async () => {
|
const checkSkillsMigration = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -286,14 +324,12 @@ function App() {
|
|||||||
checkSkillsMigration();
|
checkSkillsMigration();
|
||||||
}, [t, queryClient]);
|
}, [t, queryClient]);
|
||||||
|
|
||||||
// 切换应用时检测当前应用的环境变量冲突
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkEnvOnSwitch = async () => {
|
const checkEnvOnSwitch = async () => {
|
||||||
try {
|
try {
|
||||||
const conflicts = await checkEnvConflicts(activeApp);
|
const conflicts = await checkEnvConflicts(activeApp);
|
||||||
|
|
||||||
if (conflicts.length > 0) {
|
if (conflicts.length > 0) {
|
||||||
// 合并新检测到的冲突
|
|
||||||
setEnvConflicts((prev) => {
|
setEnvConflicts((prev) => {
|
||||||
const existingKeys = new Set(
|
const existingKeys = new Set(
|
||||||
prev.map((c) => `${c.varName}:${c.sourcePath}`),
|
prev.map((c) => `${c.varName}:${c.sourcePath}`),
|
||||||
@@ -319,7 +355,6 @@ function App() {
|
|||||||
checkEnvOnSwitch();
|
checkEnvOnSwitch();
|
||||||
}, [activeApp]);
|
}, [activeApp]);
|
||||||
|
|
||||||
// 全局键盘快捷键
|
|
||||||
const currentViewRef = useRef(currentView);
|
const currentViewRef = useRef(currentView);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -328,17 +363,14 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
// Cmd/Ctrl + , 打开设置
|
|
||||||
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
|
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setCurrentView("settings");
|
setCurrentView("settings");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESC 键返回
|
|
||||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||||
|
|
||||||
// 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理
|
|
||||||
if (document.body.style.overflow === "hidden") return;
|
if (document.body.style.overflow === "hidden") return;
|
||||||
|
|
||||||
const view = currentViewRef.current;
|
const view = currentViewRef.current;
|
||||||
@@ -356,7 +388,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 打开网站链接
|
|
||||||
const handleOpenWebsite = async (url: string) => {
|
const handleOpenWebsite = async (url: string) => {
|
||||||
try {
|
try {
|
||||||
await settingsApi.openExternal(url);
|
await settingsApi.openExternal(url);
|
||||||
@@ -370,22 +401,17 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 编辑供应商
|
|
||||||
const handleEditProvider = async (provider: Provider) => {
|
const handleEditProvider = async (provider: Provider) => {
|
||||||
await updateProvider(provider);
|
await updateProvider(provider);
|
||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确认删除/移除供应商
|
|
||||||
const handleConfirmAction = async () => {
|
const handleConfirmAction = async () => {
|
||||||
if (!confirmAction) return;
|
if (!confirmAction) return;
|
||||||
const { provider, action } = confirmAction;
|
const { provider, action } = confirmAction;
|
||||||
|
|
||||||
if (action === "remove") {
|
if (action === "remove") {
|
||||||
// Remove from live config only (for additive mode apps like OpenCode)
|
|
||||||
// Does NOT delete from database - provider remains in the list
|
|
||||||
await providersApi.removeFromLiveConfig(provider.id, activeApp);
|
await providersApi.removeFromLiveConfig(provider.id, activeApp);
|
||||||
// Invalidate queries to refresh the isInConfig state
|
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["opencodeLiveProviderIds"],
|
queryKey: ["opencodeLiveProviderIds"],
|
||||||
});
|
});
|
||||||
@@ -396,13 +422,11 @@ function App() {
|
|||||||
{ closeButton: true },
|
{ closeButton: true },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Delete from database
|
|
||||||
await deleteProvider(provider.id);
|
await deleteProvider(provider.id);
|
||||||
}
|
}
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate a unique provider key for OpenCode duplication
|
|
||||||
const generateUniqueOpencodeKey = (
|
const generateUniqueOpencodeKey = (
|
||||||
originalKey: string,
|
originalKey: string,
|
||||||
existingKeys: string[],
|
existingKeys: string[],
|
||||||
@@ -413,7 +437,6 @@ function App() {
|
|||||||
return baseKey;
|
return baseKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If -copy already exists, try -copy-2, -copy-3, ...
|
|
||||||
let counter = 2;
|
let counter = 2;
|
||||||
while (existingKeys.includes(`${baseKey}-${counter}`)) {
|
while (existingKeys.includes(`${baseKey}-${counter}`)) {
|
||||||
counter++;
|
counter++;
|
||||||
@@ -421,9 +444,7 @@ function App() {
|
|||||||
return `${baseKey}-${counter}`;
|
return `${baseKey}-${counter}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 复制供应商
|
|
||||||
const handleDuplicateProvider = async (provider: Provider) => {
|
const handleDuplicateProvider = async (provider: Provider) => {
|
||||||
// 1️⃣ 计算新的 sortIndex:如果原供应商有 sortIndex,则复制它
|
|
||||||
const newSortIndex =
|
const newSortIndex =
|
||||||
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
|
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
|
||||||
|
|
||||||
@@ -442,7 +463,6 @@ function App() {
|
|||||||
iconColor: provider.iconColor,
|
iconColor: provider.iconColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
// OpenCode: generate unique provider key (used as ID)
|
|
||||||
if (activeApp === "opencode") {
|
if (activeApp === "opencode") {
|
||||||
const existingKeys = Object.keys(providers);
|
const existingKeys = Object.keys(providers);
|
||||||
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
|
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
|
||||||
@@ -451,7 +471,6 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
|
|
||||||
if (provider.sortIndex !== undefined) {
|
if (provider.sortIndex !== undefined) {
|
||||||
const updates = Object.values(providers)
|
const updates = Object.values(providers)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -465,7 +484,6 @@ function App() {
|
|||||||
sortIndex: p.sortIndex! + 1,
|
sortIndex: p.sortIndex! + 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 先更新现有供应商的 sortIndex,为新供应商腾出位置
|
|
||||||
if (updates.length > 0) {
|
if (updates.length > 0) {
|
||||||
try {
|
try {
|
||||||
await providersApi.updateSortOrder(updates, activeApp);
|
await providersApi.updateSortOrder(updates, activeApp);
|
||||||
@@ -481,11 +499,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3️⃣ 添加复制的供应商
|
|
||||||
await addProvider(duplicatedProvider);
|
await addProvider(duplicatedProvider);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打开提供商终端
|
|
||||||
const handleOpenTerminal = async (provider: Provider) => {
|
const handleOpenTerminal = async (provider: Provider) => {
|
||||||
try {
|
try {
|
||||||
await providersApi.openTerminal(provider.id, activeApp);
|
await providersApi.openTerminal(provider.id, activeApp);
|
||||||
@@ -505,10 +521,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导入配置成功后刷新
|
|
||||||
const handleImportSuccess = async () => {
|
const handleImportSuccess = async () => {
|
||||||
try {
|
try {
|
||||||
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
|
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["providers"],
|
queryKey: ["providers"],
|
||||||
refetchType: "all",
|
refetchType: "all",
|
||||||
@@ -580,10 +594,12 @@ function App() {
|
|||||||
<UniversalProviderPanel />
|
<UniversalProviderPanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "sessions":
|
||||||
|
return <SessionManagerPage />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -605,7 +621,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
activeProviderId={activeProviderId}
|
activeProviderId={activeProviderId}
|
||||||
onSwitch={switchProvider}
|
onSwitch={switchProvider}
|
||||||
onEdit={setEditingProvider}
|
onEdit={(provider) => {
|
||||||
|
setEditingProvider(provider);
|
||||||
|
}}
|
||||||
onDelete={(provider) =>
|
onDelete={(provider) =>
|
||||||
setConfirmAction({ provider, action: "delete" })
|
setConfirmAction({ provider, action: "delete" })
|
||||||
}
|
}
|
||||||
@@ -615,6 +633,9 @@ function App() {
|
|||||||
setConfirmAction({ provider, action: "remove" })
|
setConfirmAction({ provider, action: "remove" })
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onDisableOmo={
|
||||||
|
activeApp === "opencode" ? handleDisableOmo : undefined
|
||||||
|
}
|
||||||
onDuplicate={handleDuplicateProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
onConfigureUsage={setUsageProvider}
|
onConfigureUsage={setUsageProvider}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
@@ -635,6 +656,7 @@ function App() {
|
|||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={currentView}
|
key={currentView}
|
||||||
|
className="flex-1 min-h-0"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -651,13 +673,11 @@ function App() {
|
|||||||
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
|
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
|
||||||
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
|
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
|
||||||
>
|
>
|
||||||
{/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed top-0 left-0 right-0 z-[60]"
|
className="fixed top-0 left-0 right-0 z-[60]"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
|
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
|
||||||
/>
|
/>
|
||||||
{/* 环境变量警告横幅 */}
|
|
||||||
{showEnvBanner && envConflicts.length > 0 && (
|
{showEnvBanner && envConflicts.length > 0 && (
|
||||||
<EnvWarningBanner
|
<EnvWarningBanner
|
||||||
conflicts={envConflicts}
|
conflicts={envConflicts}
|
||||||
@@ -666,7 +686,6 @@ function App() {
|
|||||||
sessionStorage.setItem("env_banner_dismissed", "true");
|
sessionStorage.setItem("env_banner_dismissed", "true");
|
||||||
}}
|
}}
|
||||||
onDeleted={async () => {
|
onDeleted={async () => {
|
||||||
// 删除后重新检测
|
|
||||||
try {
|
try {
|
||||||
const allConflicts = await checkAllEnvConflicts();
|
const allConflicts = await checkAllEnvConflicts();
|
||||||
const flatConflicts = Object.values(allConflicts).flat();
|
const flatConflicts = Object.values(allConflicts).flat();
|
||||||
@@ -732,6 +751,7 @@ function App() {
|
|||||||
t("universalProvider.title", {
|
t("universalProvider.title", {
|
||||||
defaultValue: "统一供应商",
|
defaultValue: "统一供应商",
|
||||||
})}
|
})}
|
||||||
|
{currentView === "sessions" && t("sessionManager.title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -750,13 +770,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</a>
|
</a>
|
||||||
<UpdateBadge
|
|
||||||
onClick={() => {
|
|
||||||
setSettingsDefaultTab("about");
|
|
||||||
setCurrentView("settings");
|
|
||||||
}}
|
|
||||||
className="absolute -top-4 -right-4"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -770,6 +783,12 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<UpdateBadge
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsDefaultTab("about");
|
||||||
|
setCurrentView("settings");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{isCurrentAppTakeoverActive && (
|
{isCurrentAppTakeoverActive && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -778,7 +797,7 @@ function App() {
|
|||||||
setSettingsDefaultTab("usage");
|
setSettingsDefaultTab("usage");
|
||||||
setCurrentView("settings");
|
setCurrentView("settings");
|
||||||
}}
|
}}
|
||||||
title={t("settings.usage.title", {
|
title={t("usage.title", {
|
||||||
defaultValue: "使用统计",
|
defaultValue: "使用统计",
|
||||||
})}
|
})}
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
@@ -829,6 +848,17 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{currentView === "skills" && (
|
{currentView === "skills" && (
|
||||||
<>
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
||||||
|
}
|
||||||
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<FolderArchive className="w-4 h-4 mr-2" />
|
||||||
|
{t("skills.installFromZip.button")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -915,18 +945,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
|
||||||
{/* {isClaudeApp && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentView("agents")}
|
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
title="Agents"
|
|
||||||
>
|
|
||||||
<Bot className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)} */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -936,6 +954,15 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Book className="w-4 h-4" />
|
<Book className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentView("sessions")}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
title={t("sessionManager.title")}
|
||||||
|
>
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -960,8 +987,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 pb-12 animate-fade-in ">
|
<main className="flex-1 min-h-0 flex flex-col animate-fade-in">
|
||||||
<div className="pb-12">{renderContent()}</div>
|
{renderContent()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AddProviderDialog
|
<AddProviderDialog
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface AppSwitcherProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||||
|
const STORAGE_KEY = "cc-switch-last-app";
|
||||||
|
|
||||||
export function AppSwitcher({
|
export function AppSwitcher({
|
||||||
activeApp,
|
activeApp,
|
||||||
@@ -19,6 +20,7 @@ export function AppSwitcher({
|
|||||||
}: AppSwitcherProps) {
|
}: AppSwitcherProps) {
|
||||||
const handleSwitch = (app: AppId) => {
|
const handleSwitch = (app: AppId) => {
|
||||||
if (app === activeApp) return;
|
if (app === activeApp) return;
|
||||||
|
localStorage.setItem(STORAGE_KEY, app);
|
||||||
onSwitch(app);
|
onSwitch(app);
|
||||||
};
|
};
|
||||||
const iconSize = 20;
|
const iconSize = 20;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useUpdate } from "@/contexts/UpdateContext";
|
import { useUpdate } from "@/contexts/UpdateContext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowUpCircle } from "lucide-react";
|
||||||
|
|
||||||
interface UpdateBadgeProps {
|
interface UpdateBadgeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -30,17 +31,12 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
|||||||
aria-label={title}
|
aria-label={title}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
relative h-6 w-6 rounded-full
|
relative h-8 w-8 rounded-full
|
||||||
${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"}
|
${isActive ? "text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-500/10" : "text-muted-foreground hover:bg-muted/60"}
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<span
|
<ArrowUpCircle className="h-5 w-5" />
|
||||||
className={`
|
|
||||||
absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background
|
|
||||||
${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"}
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { AppId } from "@/lib/api/types";
|
||||||
|
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||||
|
|
||||||
|
interface AppCountBarProps {
|
||||||
|
totalLabel: string;
|
||||||
|
counts: Record<AppId, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||||
|
totalLabel,
|
||||||
|
counts,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
||||||
|
<Badge variant="outline" className="bg-background/50 h-7 px-3">
|
||||||
|
{totalLabel}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
||||||
|
{APP_IDS.map((app) => (
|
||||||
|
<Badge
|
||||||
|
key={app}
|
||||||
|
variant="secondary"
|
||||||
|
className={APP_ICON_MAP[app].badgeClass}
|
||||||
|
>
|
||||||
|
<span className="opacity-75">{APP_ICON_MAP[app].label}:</span>
|
||||||
|
<span className="font-bold ml-1">{counts[app]}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { AppId } from "@/lib/api/types";
|
||||||
|
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||||
|
|
||||||
|
interface AppToggleGroupProps {
|
||||||
|
apps: Record<AppId, boolean>;
|
||||||
|
onToggle: (app: AppId, enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
||||||
|
apps,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{APP_IDS.map((app) => {
|
||||||
|
const { label, icon, activeClass } = APP_ICON_MAP[app];
|
||||||
|
const enabled = apps[app];
|
||||||
|
return (
|
||||||
|
<Tooltip key={app}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(app, !enabled)}
|
||||||
|
className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${
|
||||||
|
enabled ? activeClass : "opacity-35 hover:opacity-70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>
|
||||||
|
{label}
|
||||||
|
{enabled ? " ✓" : ""}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ListItemRowProps {
|
||||||
|
isLast?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItemRow: React.FC<ListItemRowProps> = ({
|
||||||
|
isLast,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors ${
|
||||||
|
!isLast ? "border-b border-border-default" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { SVGProps } from "react";
|
||||||
|
|
||||||
|
export function ITermIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>iTerm2</title>
|
||||||
|
<path d="M24 5.359v13.282A5.36 5.36 0 0 1 18.641 24H5.359A5.36 5.36 0 0 1 0 18.641V5.359A5.36 5.36 0 0 1 5.359 0h13.282A5.36 5.36 0 0 1 24 5.359m-.932-.233A4.196 4.196 0 0 0 18.874.932H5.126A4.196 4.196 0 0 0 .932 5.126v13.748a4.196 4.196 0 0 0 4.194 4.194h13.748a4.196 4.196 0 0 0 4.194-4.194zm-.816.233v13.282a3.613 3.613 0 0 1-3.611 3.611H5.359a3.613 3.613 0 0 1-3.611-3.611V5.359a3.613 3.613 0 0 1 3.611-3.611h13.282a3.613 3.613 0 0 1 3.611 3.611M8.854 4.194v6.495h.962V4.194zM5.483 9.493v1.085h.597V9.48q.283-.037.508-.133.373-.165.575-.448.208-.284.208-.649a.9.9 0 0 0-.171-.568 1.4 1.4 0 0 0-.426-.388 3 3 0 0 0-.544-.261 32 32 0 0 0-.545-.209 1.8 1.8 0 0 1-.426-.216q-.164-.12-.164-.284 0-.223.179-.351.18-.126.485-.127.344 0 .575.105.239.105.5.298l.433-.5a2.3 2.3 0 0 0-.605-.433 1.6 1.6 0 0 0-.582-.159v-.968h-.597v.978a2 2 0 0 0-.477.127 1.2 1.2 0 0 0-.545.411q-.194.268-.194.634 0 .335.164.56.164.224.418.38a4 4 0 0 0 .552.262q.291.104.545.209.261.104.425.238a.39.39 0 0 1 .165.321q0 .225-.187.359-.18.134-.537.134-.381 0-.717-.134a4.4 4.4 0 0 1-.649-.351l-.388.589q.209.173.477.306.276.135.575.217.191.046.373.064" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlacrittyIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Alacritty</title>
|
||||||
|
<path d="m10.065 0-8.57 21.269h3.595l6.91-16.244 6.91 16.244h3.594l-8.57-21.269zm1.935 9.935c-0.76666 1.8547-1.5334 3.7094-2.298 5.565 1.475 4.54 1.475 4.54 2.298 8.5 0.823-3.96 0.823-3.96 2.297-8.5-0.76637-1.8547-1.5315-3.7099-2.297-5.565z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WezTermIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>WezTerm</title>
|
||||||
|
<path d="M3.27 8.524c0-.623.62-1.007 2.123-1.007l-.5 2.757c-.931-.623-1.624-1.199-1.624-1.75zm4.008 6.807c0 .647-.644 1.079-2.123 1.15l.524-2.924c.931.624 1.6 1.175 1.6 1.774zm-2.625 5.992.454-2.708c3.603-.336 5.01-1.798 5.01-3.404 0-1.653-2.004-2.948-3.841-4.074l.668-3.548c.764.072 1.67.216 2.744.432l.31-2.469c-.81-.12-1.575-.168-2.29-.216L8.257 2.7l-2.363-.024-.453 2.684C1.838 5.648.43 7.158.43 8.764c0 1.63 2.004 2.876 3.841 3.954l-.668 3.716c-.859-.048-1.908-.192-3.125-.408L0 18.495c1.026.12 1.98.192 2.84.216l-.525 2.588zm15.553-1.894h2.673c.334-2.804.81-8.46 1.121-14.86h-2.553c-.071 1.51-.334 10.498-.43 11.241h-.071c-.644-2.42-1.169-4.386-1.813-6.782h-1.456c-.62 2.396-1.05 4.194-1.694 6.782h-.096c-.071-.743-.477-9.73-.525-11.24h-2.648c.31 6.399.763 12.055 1.097 14.86h2.625l1.838-7.12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GhosttyIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Ghostty</title>
|
||||||
|
<path d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KittyIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
// Official icon is complex and has fixed width/height/viewBox in original.
|
||||||
|
// Simplifying viewBox to 0 0 256 256 effectively as original was 240x240 but translated.
|
||||||
|
// Original viewBox="0 0 240 240" with g transform="translate(0 -812.362)" and elements around y=850.
|
||||||
|
// 850 - 812 = 38. So it's confusing.
|
||||||
|
// Let's copy the raw SVG content but adapt it to be a component.
|
||||||
|
// To make it behave like an icon, we should probably set viewBox="0 0 240 240" and keep the transform.
|
||||||
|
// It relies on fill colors. If we want it to be monochrome (currentColor), we should remove fills or set them to currentColor.
|
||||||
|
// However, official icons often have brand colors. The user said "official icon", which implies color.
|
||||||
|
// But usually in a dropdown we might want monochrome or original color.
|
||||||
|
// simple-icons are usually monochrome.
|
||||||
|
// Let's keep Kitty as original color since it's complex, OR mono if it works?
|
||||||
|
// The kitty icon has multiple paths with different colors. I'll preserve them for now as it's "official".
|
||||||
|
// If it looks weird in dark mode/light mode, we might need to adjust.
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 240 240"
|
||||||
|
{...props} // Allow overriding width/height
|
||||||
|
>
|
||||||
|
<g transform="translate(0 -812.362)">
|
||||||
|
<rect
|
||||||
|
width="100.446"
|
||||||
|
height="161.551"
|
||||||
|
x="72.824"
|
||||||
|
y="850.13"
|
||||||
|
ry="0"
|
||||||
|
style={{
|
||||||
|
fill: "#ddd",
|
||||||
|
fillOpacity: 1,
|
||||||
|
fillRule: "evenodd",
|
||||||
|
stroke: "none",
|
||||||
|
strokeWidth: 5.86876726,
|
||||||
|
strokeLinecap: "round",
|
||||||
|
strokeLinejoin: "round",
|
||||||
|
strokeMiterlimit: 4,
|
||||||
|
strokeDasharray: "none",
|
||||||
|
strokeOpacity: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M67.896 1029.71h104.208a7.065 7.065 0 0 0 7.065-7.066V918.436a7.065 7.065 0 0 0-7.065-7.065H67.896a7.065 7.065 0 0 0-7.065 7.065v104.208a7.065 7.065 0 0 0 7.065 7.065m55.813-38.35h37.444a4.239 4.239 0 0 1 0 8.479H123.71a4.239 4.239 0 0 1 0-8.478m-45.032-45.71a4.239 4.239 0 0 1 5.991-5.99l26.48 26.464a4.24 4.24 0 0 1 0 5.992l-26.48 26.48a4.239 4.239 0 0 1-5.991-5.992l23.484-23.484z"
|
||||||
|
style={{ strokeWidth: 1.41299629 }}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M96.085 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.385 3.574-3.385 8.17 0 4.595 1.504 8.17 3.385 8.17"
|
||||||
|
style={{
|
||||||
|
clipRule: "evenodd",
|
||||||
|
fill: "#c0c81f",
|
||||||
|
fillOpacity: 1,
|
||||||
|
fillRule: "evenodd",
|
||||||
|
strokeWidth: 3.09913683,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M193.128 836.886c-4.596-4.85-25.53 1.022-38.295 8.936-9.957-5.106-21.956-8.17-34.721-8.17-13.02 0-25.02 3.064-34.977 8.17-12.765-7.914-33.955-14.042-38.295-8.936-4.595 5.106 3.32 26.296 12.765 38.04-.766 3.064-1.276 6.128-1.276 9.446 0 10.212 4.34 19.659 11.744 27.318h42.124c-1.276-2.553.511-4.085 8.17-4.085 7.659.255 9.19 1.532 8.17 4.085h42.124c7.404-7.66 11.744-17.36 11.744-27.318 0-3.318-.51-6.382-1.276-9.446 8.935-11.744 16.594-33.189 11.999-38.04m-97.015 67.4c-8.935 0-16.339-7.404-16.339-16.34s7.404-16.339 16.34-16.339 16.339 7.404 16.339 16.34-7.404 16.339-16.34 16.339m47.997 0c-8.936 0-16.34-7.404-16.34-16.34s7.404-16.339 16.34-16.339 16.34 7.404 16.34 16.34-7.15 16.339-16.34 16.339"
|
||||||
|
style={{
|
||||||
|
clipRule: "evenodd",
|
||||||
|
fill: "#784421",
|
||||||
|
fillOpacity: 1,
|
||||||
|
fillRule: "evenodd",
|
||||||
|
strokeWidth: 2.55301046,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
||||||
|
<path
|
||||||
|
d="M168.507 903.265c15.318-19.148 46.72-28.339 67.655-15.063-24.509-3.83-46.72 2.553-67.655 15.063"
|
||||||
|
style={{
|
||||||
|
clipRule: "evenodd",
|
||||||
|
fillRule: "evenodd",
|
||||||
|
strokeWidth: 2.55301046,
|
||||||
|
fill: "#2b1100",
|
||||||
|
fillOpacity: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M167.486 898.67c8.68-20.425 34.466-33.7 55.145-26.552-21.7 2.808-39.316 11.233-55.145 26.551m-.256 9.957c15.83-15.063 50.806-20.169 61.528-4.34-21.7-6.893-40.593-3.83-61.527 4.34"
|
||||||
|
style={{
|
||||||
|
clipRule: "evenodd",
|
||||||
|
fillRule: "evenodd",
|
||||||
|
strokeWidth: 2.55301046,
|
||||||
|
fill: "#2b1100",
|
||||||
|
fillOpacity: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
||||||
|
<path
|
||||||
|
d="M71.493 903.265c-15.318-19.148-46.72-28.339-67.655-15.063 24.509-3.83 46.72 2.553 67.655 15.063"
|
||||||
|
style={{
|
||||||
|
clipRule: "evenodd",
|
||||||
|
fillRule: "evenodd",
|
||||||
|
strokeWidth: 2.55301046,
|
||||||
|
fill: "#2b1100",
|
||||||
|
fillOpacity: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M72.514 898.67c-8.68-20.425-34.466-33.7-55.145-26.552 21.7 2.808 39.316 11.233 55.145 26.551m.256 9.957c-15.83-15.063-50.806-20.169-61.528-4.34 21.7-6.893 40.593-3.83 61.527 4.34"
|
||||||
|
style={{
|
||||||
|
clipRule: "evenodd",
|
||||||
|
fillRule: "evenodd",
|
||||||
|
strokeWidth: 2.55301046,
|
||||||
|
fill: "#2b1100",
|
||||||
|
fillOpacity: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d="M52.6 893.563c-6.382 0-11.743 3.32-14.296 8.425h-.766c-6.893 0-12.765 5.106-12.765 11.489 0 8.935 9.19 13.786 17.615 10.722 5.106 7.404 16.084 7.915 20.17 0 6.126-.255 16.083-1.276 17.615-10.722 1.021-6.383-5.617-11.489-12.765-11.489h-.766c-2.042-5.106-7.659-8.425-14.041-8.425m134.8 0c6.382 0 11.743 3.32 14.296 8.425h.766c3.574 0 12.765 5.106 12.765 11.489 0 8.935-9.19 13.786-17.615 10.722-5.107 7.404-16.084 7.915-20.17 0-6.126-.255-16.083-1.276-17.615-10.722-1.021-6.383 9.19-11.489 12.765-11.489h.766c2.042-5.106 7.659-8.425 14.041-8.425"
|
||||||
|
style={{
|
||||||
|
clipRule: "evenodd",
|
||||||
|
fill: "#483737",
|
||||||
|
fillOpacity: 1,
|
||||||
|
fillRule: "evenodd",
|
||||||
|
strokeWidth: 2.55301046,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M143.542 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.386 3.574-3.386 8.17 0 4.595 1.505 8.17 3.386 8.17"
|
||||||
|
style={{
|
||||||
|
clipRule: "evenodd",
|
||||||
|
fill: "#c0c81f",
|
||||||
|
fillOpacity: 1,
|
||||||
|
fillRule: "evenodd",
|
||||||
|
strokeWidth: 3.09913683,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Server } from "lucide-react";
|
import { Server } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
useAllMcpServers,
|
useAllMcpServers,
|
||||||
useToggleMcpApp,
|
useToggleMcpApp,
|
||||||
@@ -13,19 +13,19 @@ import type { McpServer } from "@/types";
|
|||||||
import type { AppId } from "@/lib/api/types";
|
import type { AppId } from "@/lib/api/types";
|
||||||
import McpFormModal from "./McpFormModal";
|
import McpFormModal from "./McpFormModal";
|
||||||
import { ConfirmDialog } from "../ConfirmDialog";
|
import { ConfirmDialog } from "../ConfirmDialog";
|
||||||
import { Edit3, Trash2 } from "lucide-react";
|
import { Edit3, Trash2, ExternalLink } from "lucide-react";
|
||||||
import { settingsApi } from "@/lib/api";
|
import { settingsApi } from "@/lib/api";
|
||||||
import { mcpPresets } from "@/config/mcpPresets";
|
import { mcpPresets } from "@/config/mcpPresets";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { APP_IDS } from "@/config/appConfig";
|
||||||
|
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||||
|
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||||
|
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||||
|
|
||||||
interface UnifiedMcpPanelProps {
|
interface UnifiedMcpPanelProps {
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一 MCP 管理面板
|
|
||||||
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
|
|
||||||
*/
|
|
||||||
export interface UnifiedMcpPanelHandle {
|
export interface UnifiedMcpPanelHandle {
|
||||||
openAdd: () => void;
|
openAdd: () => void;
|
||||||
openImport: () => void;
|
openImport: () => void;
|
||||||
@@ -45,26 +45,22 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Queries and Mutations
|
|
||||||
const { data: serversMap, isLoading } = useAllMcpServers();
|
const { data: serversMap, isLoading } = useAllMcpServers();
|
||||||
const toggleAppMutation = useToggleMcpApp();
|
const toggleAppMutation = useToggleMcpApp();
|
||||||
const deleteServerMutation = useDeleteMcpServer();
|
const deleteServerMutation = useDeleteMcpServer();
|
||||||
const importMutation = useImportMcpFromApps();
|
const importMutation = useImportMcpFromApps();
|
||||||
|
|
||||||
// Convert serversMap to array for easier rendering
|
|
||||||
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
||||||
if (!serversMap) return [];
|
if (!serversMap) return [];
|
||||||
return Object.entries(serversMap);
|
return Object.entries(serversMap);
|
||||||
}, [serversMap]);
|
}, [serversMap]);
|
||||||
|
|
||||||
// Count enabled servers per app
|
|
||||||
const enabledCounts = useMemo(() => {
|
const enabledCounts = useMemo(() => {
|
||||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||||
serverEntries.forEach(([_, server]) => {
|
serverEntries.forEach(([_, server]) => {
|
||||||
if (server.apps.claude) counts.claude++;
|
for (const app of APP_IDS) {
|
||||||
if (server.apps.codex) counts.codex++;
|
if (server.apps[app]) counts[app]++;
|
||||||
if (server.apps.gemini) counts.gemini++;
|
}
|
||||||
if (server.apps.opencode) counts.opencode++;
|
|
||||||
});
|
});
|
||||||
return counts;
|
return counts;
|
||||||
}, [serverEntries]);
|
}, [serverEntries]);
|
||||||
@@ -77,9 +73,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
try {
|
try {
|
||||||
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
|
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("common.error"), {
|
toast.error(t("common.error"), { description: String(error) });
|
||||||
description: String(error),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,9 +100,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("common.error"), {
|
toast.error(t("common.error"), { description: String(error) });
|
||||||
description: String(error),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,9 +120,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
setConfirmDialog(null);
|
setConfirmDialog(null);
|
||||||
toast.success(t("common.success"), { closeButton: true });
|
toast.success(t("common.success"), { closeButton: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("common.error"), {
|
toast.error(t("common.error"), { description: String(error) });
|
||||||
description: String(error),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -143,18 +133,11 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
{/* Info Section */}
|
<AppCountBar
|
||||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||||
<div className="text-sm text-muted-foreground">
|
counts={enabledCounts}
|
||||||
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
/>
|
||||||
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
|
||||||
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
|
||||||
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "}
|
|
||||||
{t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - Scrollable */}
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
@@ -173,22 +156,24 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<TooltipProvider delayDuration={300}>
|
||||||
{serverEntries.map(([id, server]) => (
|
<div className="rounded-xl border border-border-default overflow-hidden">
|
||||||
<UnifiedMcpListItem
|
{serverEntries.map(([id, server], index) => (
|
||||||
key={id}
|
<UnifiedMcpListItem
|
||||||
id={id}
|
key={id}
|
||||||
server={server}
|
id={id}
|
||||||
onToggleApp={handleToggleApp}
|
server={server}
|
||||||
onEdit={handleEdit}
|
onToggleApp={handleToggleApp}
|
||||||
onDelete={handleDelete}
|
onEdit={handleEdit}
|
||||||
/>
|
onDelete={handleDelete}
|
||||||
))}
|
isLast={index === serverEntries.length - 1}
|
||||||
</div>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form Modal */}
|
|
||||||
{isFormOpen && (
|
{isFormOpen && (
|
||||||
<McpFormModal
|
<McpFormModal
|
||||||
editingId={editingId || undefined}
|
editingId={editingId || undefined}
|
||||||
@@ -205,7 +190,6 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirm Dialog */}
|
|
||||||
{confirmDialog && (
|
{confirmDialog && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={confirmDialog.isOpen}
|
isOpen={confirmDialog.isOpen}
|
||||||
@@ -221,16 +205,13 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
|
|
||||||
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
|
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一 MCP 列表项组件
|
|
||||||
* 展示服务器名称、描述,以及三个应用的复选框
|
|
||||||
*/
|
|
||||||
interface UnifiedMcpListItemProps {
|
interface UnifiedMcpListItemProps {
|
||||||
id: string;
|
id: string;
|
||||||
server: McpServer;
|
server: McpServer;
|
||||||
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
|
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
isLast?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||||
@@ -239,12 +220,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
onToggleApp,
|
onToggleApp,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
isLast,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const name = server.name || id;
|
const name = server.name || id;
|
||||||
const description = server.description || "";
|
const description = server.description || "";
|
||||||
|
|
||||||
// 匹配预设元信息
|
|
||||||
const meta = mcpPresets.find((p) => p.id === id);
|
const meta = mcpPresets.find((p) => p.id === id);
|
||||||
const docsUrl = server.docs || meta?.docs;
|
const docsUrl = server.docs || meta?.docs;
|
||||||
const homepageUrl = server.homepage || meta?.homepage;
|
const homepageUrl = server.homepage || meta?.homepage;
|
||||||
@@ -261,126 +242,66 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
|
<ListItemRow isLast={isLast}>
|
||||||
{/* 左侧:服务器信息 */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<h3 className="font-medium text-foreground">{name}</h3>
|
<span className="font-medium text-sm text-foreground truncate">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
{docsUrl && (
|
{docsUrl && (
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={openDocs}
|
onClick={openDocs}
|
||||||
|
className="text-muted-foreground/60 hover:text-foreground flex-shrink-0"
|
||||||
title={t("mcp.presets.docs")}
|
title={t("mcp.presets.docs")}
|
||||||
>
|
>
|
||||||
{t("mcp.presets.docs")}
|
<ExternalLink size={12} />
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
<p
|
||||||
|
className="text-xs text-muted-foreground truncate"
|
||||||
|
title={description}
|
||||||
|
>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!description && tags && tags.length > 0 && (
|
{!description && tags && tags.length > 0 && (
|
||||||
<p className="text-xs text-muted-foreground/70 truncate">
|
<p className="text-xs text-muted-foreground/60 truncate">
|
||||||
{tags.join(", ")}
|
{tags.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 中间:应用开关 */}
|
<AppToggleGroup
|
||||||
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
apps={server.apps}
|
||||||
<div className="flex items-center justify-between gap-3">
|
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
||||||
<label
|
/>
|
||||||
htmlFor={`${id}-claude`}
|
|
||||||
className="text-sm text-foreground/80 cursor-pointer"
|
|
||||||
>
|
|
||||||
{t("mcp.unifiedPanel.apps.claude")}
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
id={`${id}-claude`}
|
|
||||||
checked={server.apps.claude}
|
|
||||||
onCheckedChange={(checked: boolean) =>
|
|
||||||
onToggleApp(id, "claude", checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<label
|
|
||||||
htmlFor={`${id}-codex`}
|
|
||||||
className="text-sm text-foreground/80 cursor-pointer"
|
|
||||||
>
|
|
||||||
{t("mcp.unifiedPanel.apps.codex")}
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
id={`${id}-codex`}
|
|
||||||
checked={server.apps.codex}
|
|
||||||
onCheckedChange={(checked: boolean) =>
|
|
||||||
onToggleApp(id, "codex", checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<label
|
|
||||||
htmlFor={`${id}-gemini`}
|
|
||||||
className="text-sm text-foreground/80 cursor-pointer"
|
|
||||||
>
|
|
||||||
{t("mcp.unifiedPanel.apps.gemini")}
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
id={`${id}-gemini`}
|
|
||||||
checked={server.apps.gemini}
|
|
||||||
onCheckedChange={(checked: boolean) =>
|
|
||||||
onToggleApp(id, "gemini", checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<label
|
|
||||||
htmlFor={`${id}-opencode`}
|
|
||||||
className="text-sm text-foreground/80 cursor-pointer"
|
|
||||||
>
|
|
||||||
{t("mcp.unifiedPanel.apps.opencode")}
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
id={`${id}-opencode`}
|
|
||||||
checked={server.apps.opencode}
|
|
||||||
onCheckedChange={(checked: boolean) =>
|
|
||||||
onToggleApp(id, "opencode", checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧:操作按钮 */}
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
onClick={() => onEdit(id)}
|
onClick={() => onEdit(id)}
|
||||||
title={t("common.edit")}
|
title={t("common.edit")}
|
||||||
>
|
>
|
||||||
<Edit3 size={16} />
|
<Edit3 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
className="h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||||
onClick={() => onDelete(id)}
|
onClick={() => onDelete(id)}
|
||||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
|
||||||
title={t("common.delete")}
|
title={t("common.delete")}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ListItemRow>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ export function ModeToggle() {
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = (event: React.MouseEvent) => {
|
||||||
// 如果当前是 dark 或 system(且系统是暗色),切换到 light
|
// 如果当前是 dark 或 system(且系统是暗色),切换到 light
|
||||||
// 否则切换到 dark
|
// 否则切换到 dark
|
||||||
if (theme === "dark") {
|
if (theme === "dark") {
|
||||||
setTheme("light");
|
setTheme("light", event);
|
||||||
} else {
|
} else {
|
||||||
setTheme("dark");
|
setTheme("dark", event);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { UniversalProviderPanel } from "@/components/universal";
|
|||||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||||
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
|
|
||||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||||
|
|
||||||
interface AddProviderDialogProps {
|
interface AddProviderDialogProps {
|
||||||
@@ -36,7 +35,6 @@ export function AddProviderDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddProviderDialogProps) {
|
}: AddProviderDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// OpenCode doesn't support universal providers
|
|
||||||
const showUniversalTab = appId !== "opencode";
|
const showUniversalTab = appId !== "opencode";
|
||||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||||
"app-specific",
|
"app-specific",
|
||||||
@@ -45,7 +43,6 @@ export function AddProviderDialog({
|
|||||||
const [selectedUniversalPreset, setSelectedUniversalPreset] =
|
const [selectedUniversalPreset, setSelectedUniversalPreset] =
|
||||||
useState<UniversalProviderPreset | null>(null);
|
useState<UniversalProviderPreset | null>(null);
|
||||||
|
|
||||||
// Handle universal provider save
|
|
||||||
const handleUniversalProviderSave = useCallback(
|
const handleUniversalProviderSave = useCallback(
|
||||||
async (provider: UniversalProvider) => {
|
async (provider: UniversalProvider) => {
|
||||||
try {
|
try {
|
||||||
@@ -73,7 +70,6 @@ export function AddProviderDialog({
|
|||||||
[t, onOpenChange],
|
[t, onOpenChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close universal form and return to main dialog
|
|
||||||
const handleUniversalFormClose = useCallback(() => {
|
const handleUniversalFormClose = useCallback(() => {
|
||||||
setUniversalFormOpen(false);
|
setUniversalFormOpen(false);
|
||||||
setSelectedUniversalPreset(null);
|
setSelectedUniversalPreset(null);
|
||||||
@@ -86,7 +82,6 @@ export function AddProviderDialog({
|
|||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// 构造基础提交数据
|
|
||||||
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
|
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
notes: values.notes?.trim() || undefined,
|
notes: values.notes?.trim() || undefined,
|
||||||
@@ -98,7 +93,6 @@ export function AddProviderDialog({
|
|||||||
...(values.meta ? { meta: values.meta } : {}),
|
...(values.meta ? { meta: values.meta } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// OpenCode: pass providerKey for ID generation
|
|
||||||
if (appId === "opencode" && values.providerKey) {
|
if (appId === "opencode" && values.providerKey) {
|
||||||
providerData.providerKey = values.providerKey;
|
providerData.providerKey = values.providerKey;
|
||||||
}
|
}
|
||||||
@@ -107,8 +101,7 @@ export function AddProviderDialog({
|
|||||||
providerData.meta?.custom_endpoints &&
|
providerData.meta?.custom_endpoints &&
|
||||||
Object.keys(providerData.meta.custom_endpoints).length > 0;
|
Object.keys(providerData.meta.custom_endpoints).length > 0;
|
||||||
|
|
||||||
if (!hasCustomEndpoints) {
|
if (!hasCustomEndpoints && values.presetCategory !== "omo") {
|
||||||
// 收集端点候选(仅在缺少自定义端点时兜底)
|
|
||||||
const urlSet = new Set<string>();
|
const urlSet = new Set<string>();
|
||||||
|
|
||||||
const addUrl = (rawUrl?: string) => {
|
const addUrl = (rawUrl?: string) => {
|
||||||
@@ -163,7 +156,6 @@ export function AddProviderDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appId === "claude") {
|
if (appId === "claude") {
|
||||||
@@ -187,7 +179,6 @@ export function AddProviderDialog({
|
|||||||
addUrl(env.GOOGLE_GEMINI_BASE_URL);
|
addUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||||
}
|
}
|
||||||
} else if (appId === "opencode") {
|
} else if (appId === "opencode") {
|
||||||
// OpenCode uses options.baseURL
|
|
||||||
const options = parsedConfig.options as
|
const options = parsedConfig.options as
|
||||||
| Record<string, any>
|
| Record<string, any>
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -221,7 +212,6 @@ export function AddProviderDialog({
|
|||||||
[appId, onSubmit, onOpenChange],
|
[appId, onSubmit, onOpenChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 动态 footer:根据当前 Tab 显示不同按钮
|
|
||||||
const footer =
|
const footer =
|
||||||
!showUniversalTab || activeTab === "app-specific" ? (
|
!showUniversalTab || activeTab === "app-specific" ? (
|
||||||
<>
|
<>
|
||||||
@@ -296,7 +286,6 @@ export function AddProviderDialog({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
// OpenCode: directly show form without tabs
|
|
||||||
<ProviderForm
|
<ProviderForm
|
||||||
appId={appId}
|
appId={appId}
|
||||||
submitLabel={t("common.add")}
|
submitLabel={t("common.add")}
|
||||||
@@ -306,7 +295,6 @@ export function AddProviderDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Universal Provider Form Modal */}
|
|
||||||
{showUniversalTab && (
|
{showUniversalTab && (
|
||||||
<UniversalProviderFormModal
|
<UniversalProviderFormModal
|
||||||
isOpen={universalFormOpen}
|
isOpen={universalFormOpen}
|
||||||
|
|||||||
@@ -19,20 +19,20 @@ import type { AppId } from "@/lib/api";
|
|||||||
interface ProviderActionsProps {
|
interface ProviderActionsProps {
|
||||||
appId?: AppId;
|
appId?: AppId;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
/** OpenCode: 是否已添加到配置 */
|
|
||||||
isInConfig?: boolean;
|
isInConfig?: boolean;
|
||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
isProxyTakeover?: boolean;
|
isProxyTakeover?: boolean;
|
||||||
|
isOmo?: boolean;
|
||||||
|
isLastOmo?: boolean;
|
||||||
onSwitch: () => void;
|
onSwitch: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
/** OpenCode: remove from live config (not delete from database) */
|
|
||||||
onRemoveFromConfig?: () => void;
|
onRemoveFromConfig?: () => void;
|
||||||
|
onDisableOmo?: () => void;
|
||||||
onOpenTerminal?: () => void;
|
onOpenTerminal?: () => void;
|
||||||
// 故障转移相关
|
|
||||||
isAutoFailoverEnabled?: boolean;
|
isAutoFailoverEnabled?: boolean;
|
||||||
isInFailoverQueue?: boolean;
|
isInFailoverQueue?: boolean;
|
||||||
onToggleFailover?: (enabled: boolean) => void;
|
onToggleFailover?: (enabled: boolean) => void;
|
||||||
@@ -44,6 +44,8 @@ export function ProviderActions({
|
|||||||
isInConfig = false,
|
isInConfig = false,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
|
isOmo = false,
|
||||||
|
isLastOmo = false,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -51,8 +53,8 @@ export function ProviderActions({
|
|||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
|
onDisableOmo,
|
||||||
onOpenTerminal,
|
onOpenTerminal,
|
||||||
// 故障转移相关
|
|
||||||
isAutoFailoverEnabled = false,
|
isAutoFailoverEnabled = false,
|
||||||
isInFailoverQueue = false,
|
isInFailoverQueue = false,
|
||||||
onToggleFailover,
|
onToggleFailover,
|
||||||
@@ -60,19 +62,20 @@ export function ProviderActions({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const iconButtonClass = "h-8 w-8 p-1";
|
const iconButtonClass = "h-8 w-8 p-1";
|
||||||
|
|
||||||
// OpenCode 使用累加模式
|
const isOpenCodeMode = appId === "opencode" && !isOmo;
|
||||||
const isOpenCodeMode = appId === "opencode";
|
|
||||||
|
|
||||||
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
|
|
||||||
const isFailoverMode =
|
const isFailoverMode =
|
||||||
!isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
|
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||||||
|
|
||||||
// 处理主按钮点击
|
|
||||||
const handleMainButtonClick = () => {
|
const handleMainButtonClick = () => {
|
||||||
if (isOpenCodeMode) {
|
if (isOmo) {
|
||||||
// OpenCode 模式:切换配置状态(添加/移除)
|
if (isCurrent) {
|
||||||
|
onDisableOmo?.();
|
||||||
|
} else {
|
||||||
|
onSwitch();
|
||||||
|
}
|
||||||
|
} else if (isOpenCodeMode) {
|
||||||
if (isInConfig) {
|
if (isInConfig) {
|
||||||
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
|
|
||||||
if (onRemoveFromConfig) {
|
if (onRemoveFromConfig) {
|
||||||
onRemoveFromConfig();
|
onRemoveFromConfig();
|
||||||
} else {
|
} else {
|
||||||
@@ -82,17 +85,33 @@ export function ProviderActions({
|
|||||||
onSwitch(); // 添加到配置
|
onSwitch(); // 添加到配置
|
||||||
}
|
}
|
||||||
} else if (isFailoverMode) {
|
} else if (isFailoverMode) {
|
||||||
// 故障转移模式:切换队列状态
|
|
||||||
onToggleFailover(!isInFailoverQueue);
|
onToggleFailover(!isInFailoverQueue);
|
||||||
} else {
|
} else {
|
||||||
// 普通模式:切换供应商
|
|
||||||
onSwitch();
|
onSwitch();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 主按钮的状态和样式
|
|
||||||
const getMainButtonState = () => {
|
const getMainButtonState = () => {
|
||||||
// OpenCode 累加模式
|
if (isOmo) {
|
||||||
|
if (isCurrent) {
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
className:
|
||||||
|
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||||
|
icon: <Check className="h-4 w-4" />,
|
||||||
|
text: t("provider.inUse"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "default" as const,
|
||||||
|
className: "",
|
||||||
|
icon: <Play className="h-4 w-4" />,
|
||||||
|
text: t("provider.enable"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (isOpenCodeMode) {
|
if (isOpenCodeMode) {
|
||||||
if (isInConfig) {
|
if (isInConfig) {
|
||||||
return {
|
return {
|
||||||
@@ -114,7 +133,6 @@ export function ProviderActions({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 故障转移模式
|
|
||||||
if (isFailoverMode) {
|
if (isFailoverMode) {
|
||||||
if (isInFailoverQueue) {
|
if (isInFailoverQueue) {
|
||||||
return {
|
return {
|
||||||
@@ -136,7 +154,6 @@ export function ProviderActions({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 普通模式
|
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
return {
|
return {
|
||||||
disabled: true,
|
disabled: true,
|
||||||
@@ -161,8 +178,11 @@ export function ProviderActions({
|
|||||||
|
|
||||||
const buttonState = getMainButtonState();
|
const buttonState = getMainButtonState();
|
||||||
|
|
||||||
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
|
const canDelete = isOmo
|
||||||
const canDelete = isOpenCodeMode ? true : !isCurrent;
|
? !(isLastOmo && isCurrent)
|
||||||
|
: isOpenCodeMode
|
||||||
|
? true
|
||||||
|
: !isCurrent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ interface ProviderCardProps {
|
|||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||||||
|
isOmo?: boolean;
|
||||||
|
isLastOmo?: boolean;
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
/** OpenCode: remove from live config (not delete from database) */
|
|
||||||
onRemoveFromConfig?: (provider: Provider) => void;
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
|
onDisableOmo?: () => void;
|
||||||
onConfigureUsage: (provider: Provider) => void;
|
onConfigureUsage: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
@@ -41,7 +43,6 @@ interface ProviderCardProps {
|
|||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||||
dragHandleProps?: DragHandleProps;
|
dragHandleProps?: DragHandleProps;
|
||||||
// 故障转移相关
|
|
||||||
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
|
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
|
||||||
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...)
|
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...)
|
||||||
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||||
@@ -50,17 +51,14 @@ interface ProviderCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||||
// 优先级 1: 备注
|
|
||||||
if (provider.notes?.trim()) {
|
if (provider.notes?.trim()) {
|
||||||
return provider.notes.trim();
|
return provider.notes.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先级 2: 官网地址
|
|
||||||
if (provider.websiteUrl) {
|
if (provider.websiteUrl) {
|
||||||
return provider.websiteUrl;
|
return provider.websiteUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先级 3: 从配置中提取请求地址
|
|
||||||
const config = provider.settingsConfig;
|
const config = provider.settingsConfig;
|
||||||
|
|
||||||
if (config && typeof config === "object") {
|
if (config && typeof config === "object") {
|
||||||
@@ -89,10 +87,13 @@ export function ProviderCard({
|
|||||||
isCurrent,
|
isCurrent,
|
||||||
appId,
|
appId,
|
||||||
isInConfig = true,
|
isInConfig = true,
|
||||||
|
isOmo = false,
|
||||||
|
isLastOmo = false,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
|
onDisableOmo,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -102,7 +103,6 @@ export function ProviderCard({
|
|||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
// 故障转移相关
|
|
||||||
isAutoFailoverEnabled = false,
|
isAutoFailoverEnabled = false,
|
||||||
failoverPriority,
|
failoverPriority,
|
||||||
isInFailoverQueue = false,
|
isInFailoverQueue = false,
|
||||||
@@ -111,7 +111,6 @@ export function ProviderCard({
|
|||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 获取供应商健康状态
|
|
||||||
const { data: health } = useProviderHealth(provider.id, appId);
|
const { data: health } = useProviderHealth(provider.id, appId);
|
||||||
|
|
||||||
const fallbackUrlText = t("provider.notConfigured", {
|
const fallbackUrlText = t("provider.notConfigured", {
|
||||||
@@ -122,24 +121,18 @@ export function ProviderCard({
|
|||||||
return extractApiUrl(provider, fallbackUrlText);
|
return extractApiUrl(provider, fallbackUrlText);
|
||||||
}, [provider, fallbackUrlText]);
|
}, [provider, fallbackUrlText]);
|
||||||
|
|
||||||
// 判断是否为可点击的 URL(备注不可点击)
|
|
||||||
const isClickableUrl = useMemo(() => {
|
const isClickableUrl = useMemo(() => {
|
||||||
// 如果有备注,则不可点击
|
|
||||||
if (provider.notes?.trim()) {
|
if (provider.notes?.trim()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 如果显示的是回退文本,也不可点击
|
|
||||||
if (displayUrl === fallbackUrlText) {
|
if (displayUrl === fallbackUrlText) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 其他情况(官网地址或请求地址)可点击
|
|
||||||
return true;
|
return true;
|
||||||
}, [provider.notes, displayUrl, fallbackUrlText]);
|
}, [provider.notes, displayUrl, fallbackUrlText]);
|
||||||
|
|
||||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||||
|
|
||||||
// 获取用量数据以判断是否有多套餐
|
|
||||||
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
|
|
||||||
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||||
const autoQueryInterval = shouldAutoQuery
|
const autoQueryInterval = shouldAutoQuery
|
||||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||||
@@ -153,21 +146,17 @@ export function ProviderCard({
|
|||||||
const hasMultiplePlans =
|
const hasMultiplePlans =
|
||||||
usage?.success && usage.data && usage.data.length > 1;
|
usage?.success && usage.data && usage.data.length > 1;
|
||||||
|
|
||||||
// 多套餐默认展开
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
// 操作按钮容器 ref,用于动态计算宽度
|
|
||||||
const actionsRef = useRef<HTMLDivElement>(null);
|
const actionsRef = useRef<HTMLDivElement>(null);
|
||||||
const [actionsWidth, setActionsWidth] = useState(0);
|
const [actionsWidth, setActionsWidth] = useState(0);
|
||||||
|
|
||||||
// 当检测到多套餐时自动展开
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMultiplePlans) {
|
if (hasMultiplePlans) {
|
||||||
setIsExpanded(true);
|
setIsExpanded(true);
|
||||||
}
|
}
|
||||||
}, [hasMultiplePlans]);
|
}, [hasMultiplePlans]);
|
||||||
|
|
||||||
// 动态获取操作按钮宽度
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionsRef.current) {
|
if (actionsRef.current) {
|
||||||
const updateWidth = () => {
|
const updateWidth = () => {
|
||||||
@@ -175,7 +164,6 @@ export function ProviderCard({
|
|||||||
setActionsWidth(width);
|
setActionsWidth(width);
|
||||||
};
|
};
|
||||||
updateWidth();
|
updateWidth();
|
||||||
// 监听窗口大小变化
|
|
||||||
window.addEventListener("resize", updateWidth);
|
window.addEventListener("resize", updateWidth);
|
||||||
return () => window.removeEventListener("resize", updateWidth);
|
return () => window.removeEventListener("resize", updateWidth);
|
||||||
}
|
}
|
||||||
@@ -188,32 +176,27 @@ export function ProviderCard({
|
|||||||
onOpenWebsite(displayUrl);
|
onOpenWebsite(displayUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 判断是否是"当前使用中"的供应商
|
const isActiveProvider = isOmo
|
||||||
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
|
? isCurrent
|
||||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
: appId === "opencode"
|
||||||
// - 代理接管模式(非故障转移):isCurrent
|
|
||||||
// - 普通模式:isCurrent
|
|
||||||
const isActiveProvider =
|
|
||||||
appId === "opencode"
|
|
||||||
? false
|
? false
|
||||||
: isAutoFailoverEnabled
|
: isAutoFailoverEnabled
|
||||||
? activeProviderId === provider.id
|
? activeProviderId === provider.id
|
||||||
: isCurrent;
|
: isCurrent;
|
||||||
|
|
||||||
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
|
||||||
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
const shouldUseBlue =
|
||||||
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
|
(isOmo && isActiveProvider) ||
|
||||||
|
(!isOmo && !isProxyTakeover && isActiveProvider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
||||||
"bg-card text-card-foreground group",
|
"bg-card text-card-foreground group",
|
||||||
// hover 时的边框效果
|
|
||||||
isAutoFailoverEnabled || isProxyTakeover
|
isAutoFailoverEnabled || isProxyTakeover
|
||||||
? "hover:border-emerald-500/50"
|
? "hover:border-emerald-500/50"
|
||||||
: "hover:border-border-active",
|
: "hover:border-border-active",
|
||||||
// 当前激活的供应商边框样式
|
|
||||||
shouldUseGreen &&
|
shouldUseGreen &&
|
||||||
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||||
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||||
@@ -225,7 +208,6 @@ export function ProviderCard({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||||
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
|
|
||||||
shouldUseGreen && "from-emerald-500/10",
|
shouldUseGreen && "from-emerald-500/10",
|
||||||
shouldUseBlue && "from-blue-500/10",
|
shouldUseBlue && "from-blue-500/10",
|
||||||
!isActiveProvider && "from-primary/10",
|
!isActiveProvider && "from-primary/10",
|
||||||
@@ -248,7 +230,6 @@ export function ProviderCard({
|
|||||||
<GripVertical className="h-4 w-4" />
|
<GripVertical className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 供应商图标 */}
|
|
||||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
icon={provider.icon}
|
icon={provider.icon}
|
||||||
@@ -264,14 +245,18 @@ export function ProviderCard({
|
|||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* 健康状态徽章 */}
|
{isOmo && (
|
||||||
|
<span className="inline-flex items-center rounded-md bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 dark:bg-violet-900/40 dark:text-violet-300">
|
||||||
|
OMO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{isProxyRunning && isInFailoverQueue && health && (
|
{isProxyRunning && isInFailoverQueue && health && (
|
||||||
<ProviderHealthBadge
|
<ProviderHealthBadge
|
||||||
consecutiveFailures={health.consecutive_failures}
|
consecutiveFailures={health.consecutive_failures}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 故障转移优先级徽章 */}
|
|
||||||
{isAutoFailoverEnabled &&
|
{isAutoFailoverEnabled &&
|
||||||
isInFailoverQueue &&
|
isInFailoverQueue &&
|
||||||
failoverPriority && (
|
failoverPriority && (
|
||||||
@@ -318,10 +303,8 @@ export function ProviderCard({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
|
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
|
||||||
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
|
||||||
{hasMultiplePlans ? (
|
{hasMultiplePlans ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
@@ -342,7 +325,6 @@ export function ProviderCard({
|
|||||||
inline={true}
|
inline={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* 展开/折叠按钮 - 仅在有多套餐时显示 */}
|
|
||||||
{hasMultiplePlans && (
|
{hasMultiplePlans && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -366,7 +348,6 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
|
|
||||||
<div
|
<div
|
||||||
ref={actionsRef}
|
ref={actionsRef}
|
||||||
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
||||||
@@ -377,6 +358,8 @@ export function ProviderCard({
|
|||||||
isInConfig={isInConfig}
|
isInConfig={isInConfig}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
isProxyTakeover={isProxyTakeover}
|
isProxyTakeover={isProxyTakeover}
|
||||||
|
isOmo={isOmo}
|
||||||
|
isLastOmo={isLastOmo}
|
||||||
onSwitch={() => onSwitch(provider)}
|
onSwitch={() => onSwitch(provider)}
|
||||||
onEdit={() => onEdit(provider)}
|
onEdit={() => onEdit(provider)}
|
||||||
onDuplicate={() => onDuplicate(provider)}
|
onDuplicate={() => onDuplicate(provider)}
|
||||||
@@ -388,10 +371,10 @@ export function ProviderCard({
|
|||||||
? () => onRemoveFromConfig(provider)
|
? () => onRemoveFromConfig(provider)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onDisableOmo={onDisableOmo}
|
||||||
onOpenTerminal={
|
onOpenTerminal={
|
||||||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||||
}
|
}
|
||||||
// 故障转移相关
|
|
||||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
isInFailoverQueue={isInFailoverQueue}
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
onToggleFailover={onToggleFailover}
|
onToggleFailover={onToggleFailover}
|
||||||
@@ -400,7 +383,6 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 展开的完整套餐列表 */}
|
|
||||||
{isExpanded && hasMultiplePlans && (
|
{isExpanded && hasMultiplePlans && (
|
||||||
<div className="mt-4 pt-4 border-t border-border-default">
|
<div className="mt-4 pt-4 border-t border-border-default">
|
||||||
<UsageFooter
|
<UsageFooter
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import type { Provider } from "@/types";
|
|||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import { providersApi } from "@/lib/api/providers";
|
import { providersApi } from "@/lib/api/providers";
|
||||||
import { useDragSort } from "@/hooks/useDragSort";
|
import { useDragSort } from "@/hooks/useDragSort";
|
||||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
|
||||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +28,7 @@ import {
|
|||||||
useAddToFailoverQueue,
|
useAddToFailoverQueue,
|
||||||
useRemoveFromFailoverQueue,
|
useRemoveFromFailoverQueue,
|
||||||
} from "@/lib/query/failover";
|
} from "@/lib/query/failover";
|
||||||
|
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -40,8 +40,8 @@ interface ProviderListProps {
|
|||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
/** OpenCode: remove from live config (not delete from database) */
|
|
||||||
onRemoveFromConfig?: (provider: Provider) => void;
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
|
onDisableOmo?: () => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
@@ -61,6 +61,7 @@ export function ProviderList({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
|
onDisableOmo,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -77,14 +78,12 @@ export function ProviderList({
|
|||||||
appId,
|
appId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
|
|
||||||
const { data: opencodeLiveIds } = useQuery({
|
const { data: opencodeLiveIds } = useQuery({
|
||||||
queryKey: ["opencodeLiveProviderIds"],
|
queryKey: ["opencodeLiveProviderIds"],
|
||||||
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
||||||
enabled: appId === "opencode",
|
enabled: appId === "opencode",
|
||||||
});
|
});
|
||||||
|
|
||||||
// OpenCode: 判断供应商是否已添加到 opencode.json
|
|
||||||
const isProviderInConfig = useCallback(
|
const isProviderInConfig = useCallback(
|
||||||
(providerId: string): boolean => {
|
(providerId: string): boolean => {
|
||||||
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
||||||
@@ -93,20 +92,18 @@ export function ProviderList({
|
|||||||
[appId, opencodeLiveIds],
|
[appId, opencodeLiveIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 流式健康检查
|
|
||||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
|
||||||
|
|
||||||
// 故障转移相关
|
|
||||||
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||||
const { data: failoverQueue } = useFailoverQueue(appId);
|
const { data: failoverQueue } = useFailoverQueue(appId);
|
||||||
const addToQueue = useAddToFailoverQueue();
|
const addToQueue = useAddToFailoverQueue();
|
||||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||||
|
|
||||||
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
|
|
||||||
const isFailoverModeActive =
|
const isFailoverModeActive =
|
||||||
isProxyTakeover === true && isAutoFailoverEnabled === true;
|
isProxyTakeover === true && isAutoFailoverEnabled === true;
|
||||||
|
|
||||||
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
|
const isOpenCode = appId === "opencode";
|
||||||
|
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
|
||||||
|
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
|
||||||
|
|
||||||
const getFailoverPriority = useCallback(
|
const getFailoverPriority = useCallback(
|
||||||
(providerId: string): number | undefined => {
|
(providerId: string): number | undefined => {
|
||||||
if (!isFailoverModeActive || !failoverQueue) return undefined;
|
if (!isFailoverModeActive || !failoverQueue) return undefined;
|
||||||
@@ -118,7 +115,6 @@ export function ProviderList({
|
|||||||
[isFailoverModeActive, failoverQueue],
|
[isFailoverModeActive, failoverQueue],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 判断供应商是否在故障转移队列中
|
|
||||||
const isInFailoverQueue = useCallback(
|
const isInFailoverQueue = useCallback(
|
||||||
(providerId: string): boolean => {
|
(providerId: string): boolean => {
|
||||||
if (!isFailoverModeActive || !failoverQueue) return false;
|
if (!isFailoverModeActive || !failoverQueue) return false;
|
||||||
@@ -127,7 +123,6 @@ export function ProviderList({
|
|||||||
[isFailoverModeActive, failoverQueue],
|
[isFailoverModeActive, failoverQueue],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 切换供应商的故障转移队列状态
|
|
||||||
const handleToggleFailover = useCallback(
|
const handleToggleFailover = useCallback(
|
||||||
(providerId: string, enabled: boolean) => {
|
(providerId: string, enabled: boolean) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -139,10 +134,6 @@ export function ProviderList({
|
|||||||
[appId, addToQueue, removeFromQueue],
|
[appId, addToQueue, removeFromQueue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTest = (provider: Provider) => {
|
|
||||||
checkProvider(provider.id, provider.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -214,35 +205,44 @@ export function ProviderList({
|
|||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredProviders.map((provider) => (
|
{filteredProviders.map((provider) => {
|
||||||
<SortableProviderCard
|
const isOmo = provider.category === "omo";
|
||||||
key={provider.id}
|
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
|
||||||
provider={provider}
|
return (
|
||||||
isCurrent={provider.id === currentProviderId}
|
<SortableProviderCard
|
||||||
appId={appId}
|
key={provider.id}
|
||||||
isInConfig={isProviderInConfig(provider.id)}
|
provider={provider}
|
||||||
onSwitch={onSwitch}
|
isCurrent={
|
||||||
onEdit={onEdit}
|
isOmo ? isOmoCurrent : provider.id === currentProviderId
|
||||||
onDelete={onDelete}
|
}
|
||||||
onRemoveFromConfig={onRemoveFromConfig}
|
appId={appId}
|
||||||
onDuplicate={onDuplicate}
|
isInConfig={isProviderInConfig(provider.id)}
|
||||||
onConfigureUsage={onConfigureUsage}
|
isOmo={isOmo}
|
||||||
onOpenWebsite={onOpenWebsite}
|
isLastOmo={
|
||||||
onOpenTerminal={onOpenTerminal}
|
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
|
||||||
onTest={appId !== "opencode" ? handleTest : undefined}
|
}
|
||||||
isTesting={isChecking(provider.id)}
|
onSwitch={onSwitch}
|
||||||
isProxyRunning={isProxyRunning}
|
onEdit={onEdit}
|
||||||
isProxyTakeover={isProxyTakeover}
|
onDelete={onDelete}
|
||||||
// 故障转移相关:联动状态
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
isAutoFailoverEnabled={isFailoverModeActive}
|
onDisableOmo={onDisableOmo}
|
||||||
failoverPriority={getFailoverPriority(provider.id)}
|
onDuplicate={onDuplicate}
|
||||||
isInFailoverQueue={isInFailoverQueue(provider.id)}
|
onConfigureUsage={onConfigureUsage}
|
||||||
onToggleFailover={(enabled) =>
|
onOpenWebsite={onOpenWebsite}
|
||||||
handleToggleFailover(provider.id, enabled)
|
onOpenTerminal={onOpenTerminal}
|
||||||
}
|
isTesting={false} // isChecking(provider.id) - 测试功能已隐藏
|
||||||
activeProviderId={activeProviderId}
|
isProxyRunning={isProxyRunning}
|
||||||
/>
|
isProxyTakeover={isProxyTakeover}
|
||||||
))}
|
isAutoFailoverEnabled={isFailoverModeActive}
|
||||||
|
failoverPriority={getFailoverPriority(provider.id)}
|
||||||
|
isInFailoverQueue={isInFailoverQueue(provider.id)}
|
||||||
|
onToggleFailover={(enabled) =>
|
||||||
|
handleToggleFailover(provider.id, enabled)
|
||||||
|
}
|
||||||
|
activeProviderId={activeProviderId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
@@ -332,11 +332,13 @@ interface SortableProviderCardProps {
|
|||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
isInConfig: boolean;
|
isInConfig: boolean;
|
||||||
|
isOmo: boolean;
|
||||||
|
isLastOmo: boolean;
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
/** OpenCode: remove from live config (not delete from database) */
|
|
||||||
onRemoveFromConfig?: (provider: Provider) => void;
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
|
onDisableOmo?: () => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
@@ -345,7 +347,6 @@ interface SortableProviderCardProps {
|
|||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover: boolean;
|
isProxyTakeover: boolean;
|
||||||
// 故障转移相关
|
|
||||||
isAutoFailoverEnabled: boolean;
|
isAutoFailoverEnabled: boolean;
|
||||||
failoverPriority?: number;
|
failoverPriority?: number;
|
||||||
isInFailoverQueue: boolean;
|
isInFailoverQueue: boolean;
|
||||||
@@ -358,10 +359,13 @@ function SortableProviderCard({
|
|||||||
isCurrent,
|
isCurrent,
|
||||||
appId,
|
appId,
|
||||||
isInConfig,
|
isInConfig,
|
||||||
|
isOmo,
|
||||||
|
isLastOmo,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
|
onDisableOmo,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -397,10 +401,13 @@ function SortableProviderCard({
|
|||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
isInConfig={isInConfig}
|
isInConfig={isInConfig}
|
||||||
|
isOmo={isOmo}
|
||||||
|
isLastOmo={isLastOmo}
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRemoveFromConfig={onRemoveFromConfig}
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
|
onDisableOmo={onDisableOmo}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={
|
onConfigureUsage={
|
||||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||||
@@ -416,7 +423,6 @@ function SortableProviderCard({
|
|||||||
listeners,
|
listeners,
|
||||||
isDragging,
|
isDragging,
|
||||||
}}
|
}}
|
||||||
// 故障转移相关
|
|
||||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
failoverPriority={failoverPriority}
|
failoverPriority={failoverPriority}
|
||||||
isInFailoverQueue={isInFailoverQueue}
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FormLabel } from "@/components/ui/form";
|
import { FormLabel } from "@/components/ui/form";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||||
import { ApiKeySection, EndpointField } from "./shared";
|
import { ApiKeySection, EndpointField } from "./shared";
|
||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
|
||||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||||
|
|
||||||
interface EndpointCandidate {
|
interface EndpointCandidate {
|
||||||
@@ -59,10 +65,9 @@ interface ClaudeFormFieldsProps {
|
|||||||
// Speed Test Endpoints
|
// Speed Test Endpoints
|
||||||
speedTestEndpoints: EndpointCandidate[];
|
speedTestEndpoints: EndpointCandidate[];
|
||||||
|
|
||||||
// OpenRouter Compat
|
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||||
showOpenRouterCompatToggle: boolean;
|
apiFormat: ClaudeApiFormat;
|
||||||
openRouterCompatEnabled: boolean;
|
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||||
onOpenRouterCompatChange: (enabled: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeFormFields({
|
export function ClaudeFormFields({
|
||||||
@@ -95,9 +100,8 @@ export function ClaudeFormFields({
|
|||||||
defaultOpusModel,
|
defaultOpusModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
speedTestEndpoints,
|
speedTestEndpoints,
|
||||||
showOpenRouterCompatToggle,
|
apiFormat,
|
||||||
openRouterCompatEnabled,
|
onApiFormatChange,
|
||||||
onOpenRouterCompatChange,
|
|
||||||
}: ClaudeFormFieldsProps) {
|
}: ClaudeFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -159,7 +163,11 @@ export function ClaudeFormFields({
|
|||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={onBaseUrlChange}
|
onChange={onBaseUrlChange}
|
||||||
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
||||||
hint={t("providerForm.apiHint")}
|
hint={
|
||||||
|
apiFormat === "openai_chat"
|
||||||
|
? t("providerForm.apiHintOAI")
|
||||||
|
: t("providerForm.apiHint")
|
||||||
|
}
|
||||||
onManageClick={() => onEndpointModalToggle(true)}
|
onManageClick={() => onEndpointModalToggle(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -180,25 +188,34 @@ export function ClaudeFormFields({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showOpenRouterCompatToggle && (
|
{/* API 格式选择(仅非官方供应商显示) */}
|
||||||
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
|
{shouldShowModelSelector && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<FormLabel>
|
<FormLabel htmlFor="apiFormat">
|
||||||
{t("providerForm.openrouterCompatMode", {
|
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||||
defaultValue: "OpenRouter 兼容模式",
|
</FormLabel>
|
||||||
})}
|
<Select value={apiFormat} onValueChange={onApiFormatChange}>
|
||||||
</FormLabel>
|
<SelectTrigger id="apiFormat" className="w-full">
|
||||||
<p className="text-xs text-muted-foreground">
|
<SelectValue />
|
||||||
{t("providerForm.openrouterCompatModeHint", {
|
</SelectTrigger>
|
||||||
defaultValue:
|
<SelectContent>
|
||||||
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
<SelectItem value="anthropic">
|
||||||
})}
|
{t("providerForm.apiFormatAnthropic", {
|
||||||
</p>
|
defaultValue: "Anthropic Messages (原生)",
|
||||||
</div>
|
})}
|
||||||
<Switch
|
</SelectItem>
|
||||||
checked={openRouterCompatEnabled}
|
<SelectItem value="openai_chat">
|
||||||
onCheckedChange={onOpenRouterCompatChange}
|
{t("providerForm.apiFormatOpenAIChat", {
|
||||||
/>
|
defaultValue: "OpenAI Chat Completions (需转换)",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerForm.apiFormatHint", {
|
||||||
|
defaultValue: "选择供应商 API 的输入格式",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Save, FolderInput, Loader2 } from "lucide-react";
|
||||||
|
import JsonEditor from "@/components/JsonEditor";
|
||||||
|
import {
|
||||||
|
OmoGlobalConfigFields,
|
||||||
|
type OmoGlobalConfigFieldsRef,
|
||||||
|
} from "./OmoGlobalConfigFields";
|
||||||
|
import type { OmoGlobalConfig } from "@/types/omo";
|
||||||
|
|
||||||
|
interface OmoCommonConfigEditorProps {
|
||||||
|
previewValue: string;
|
||||||
|
useCommonConfig: boolean;
|
||||||
|
onCommonConfigToggle: (checked: boolean) => void;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
onEditClick: () => void;
|
||||||
|
onModalClose: () => void;
|
||||||
|
onSave: () => Promise<void>;
|
||||||
|
isSaving: boolean;
|
||||||
|
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
|
||||||
|
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
|
||||||
|
fieldsKey: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OmoCommonConfigEditor({
|
||||||
|
previewValue,
|
||||||
|
useCommonConfig,
|
||||||
|
onCommonConfigToggle,
|
||||||
|
isModalOpen,
|
||||||
|
onEditClick,
|
||||||
|
onModalClose,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
onGlobalConfigStateChange,
|
||||||
|
globalConfigRef,
|
||||||
|
fieldsKey,
|
||||||
|
}: OmoCommonConfigEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const syncDarkMode = () =>
|
||||||
|
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||||
|
syncDarkMode();
|
||||||
|
const observer = new MutationObserver(syncDarkMode);
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
});
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
const handleImportLocal = async () => {
|
||||||
|
if (!globalConfigRef.current) return;
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
await globalConfigRef.current.importFromLocal();
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>{t("provider.configJson")}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useCommonConfig}
|
||||||
|
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t("omo.writeCommonConfig", {
|
||||||
|
defaultValue: "Write to common config",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditClick}
|
||||||
|
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
{t("omo.editCommonConfig", { defaultValue: "Edit common config" })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<JsonEditor
|
||||||
|
value={previewValue}
|
||||||
|
onChange={() => {}}
|
||||||
|
darkMode={isDarkMode}
|
||||||
|
rows={14}
|
||||||
|
showValidation={false}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FullScreenPanel
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
title={t("omo.editCommonConfigTitle", {
|
||||||
|
defaultValue: "Edit OMO Common Config",
|
||||||
|
})}
|
||||||
|
onClose={onModalClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleImportLocal}
|
||||||
|
disabled={isImporting}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isImporting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FolderInput className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{t("common.import", { defaultValue: "Import" })}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={onModalClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("omo.commonConfigHint", {
|
||||||
|
defaultValue:
|
||||||
|
"OMO common config will be merged into all OMO configs that enable it",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<OmoGlobalConfigFields
|
||||||
|
key={fieldsKey}
|
||||||
|
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
|
||||||
|
onStateChange={onGlobalConfigStateChange}
|
||||||
|
hideSaveButtons
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FullScreenPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,739 @@
|
|||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
FolderInput,
|
||||||
|
RotateCcw,
|
||||||
|
ChevronsUpDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { OmoGlobalConfig } from "@/types/omo";
|
||||||
|
import {
|
||||||
|
OMO_DISABLEABLE_AGENTS,
|
||||||
|
OMO_DISABLEABLE_MCPS,
|
||||||
|
OMO_DISABLEABLE_HOOKS,
|
||||||
|
OMO_DISABLEABLE_SKILLS,
|
||||||
|
OMO_DEFAULT_SCHEMA_URL,
|
||||||
|
OMO_SISYPHUS_AGENT_PLACEHOLDER,
|
||||||
|
OMO_LSP_PLACEHOLDER,
|
||||||
|
OMO_EXPERIMENTAL_PLACEHOLDER,
|
||||||
|
OMO_BACKGROUND_TASK_PLACEHOLDER,
|
||||||
|
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
|
||||||
|
OMO_CLAUDE_CODE_PLACEHOLDER,
|
||||||
|
} from "@/types/omo";
|
||||||
|
import {
|
||||||
|
useOmoGlobalConfig,
|
||||||
|
useSaveOmoGlobalConfig,
|
||||||
|
useReadOmoLocalFile,
|
||||||
|
} from "@/lib/query/omo";
|
||||||
|
|
||||||
|
interface PresetOption {
|
||||||
|
readonly value: string;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OmoGlobalConfigFieldsRef {
|
||||||
|
buildCurrentConfig: () => OmoGlobalConfig;
|
||||||
|
buildCurrentConfigStrict: () => OmoGlobalConfig;
|
||||||
|
importFromLocal: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OmoGlobalConfigFieldsProps {
|
||||||
|
onStateChange?: (config: OmoGlobalConfig) => void;
|
||||||
|
hideSaveButtons?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OmoAdvancedFieldKey =
|
||||||
|
| "lspStr"
|
||||||
|
| "experimentalStr"
|
||||||
|
| "backgroundTaskStr"
|
||||||
|
| "browserStr"
|
||||||
|
| "claudeCodeStr";
|
||||||
|
|
||||||
|
const OMO_ADVANCED_JSON_FIELDS: ReadonlyArray<{
|
||||||
|
key: OmoAdvancedFieldKey;
|
||||||
|
labelKey: string;
|
||||||
|
defaultLabel: string;
|
||||||
|
placeholder: string;
|
||||||
|
minHeight: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: "lspStr",
|
||||||
|
labelKey: "omo.advancedLsp",
|
||||||
|
defaultLabel: "LSP Config",
|
||||||
|
placeholder: OMO_LSP_PLACEHOLDER,
|
||||||
|
minHeight: "200px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "experimentalStr",
|
||||||
|
labelKey: "omo.advancedExperimental",
|
||||||
|
defaultLabel: "Experimental Features",
|
||||||
|
placeholder: OMO_EXPERIMENTAL_PLACEHOLDER,
|
||||||
|
minHeight: "120px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "backgroundTaskStr",
|
||||||
|
labelKey: "omo.advancedBackgroundTask",
|
||||||
|
defaultLabel: "Background Tasks",
|
||||||
|
placeholder: OMO_BACKGROUND_TASK_PLACEHOLDER,
|
||||||
|
minHeight: "250px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "browserStr",
|
||||||
|
labelKey: "omo.advancedBrowserAutomation",
|
||||||
|
defaultLabel: "Browser Automation",
|
||||||
|
placeholder: OMO_BROWSER_AUTOMATION_PLACEHOLDER,
|
||||||
|
minHeight: "80px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "claudeCodeStr",
|
||||||
|
labelKey: "omo.advancedClaudeCode",
|
||||||
|
defaultLabel: "Claude Code",
|
||||||
|
placeholder: OMO_CLAUDE_CODE_PLACEHOLDER,
|
||||||
|
minHeight: "180px",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function TagListEditor({
|
||||||
|
label,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
presets,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
values: string[];
|
||||||
|
onChange: (values: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
presets?: readonly PresetOption[];
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const toggleValue = (v: string) => {
|
||||||
|
if (values.includes(v)) {
|
||||||
|
onChange(values.filter((x) => x !== v));
|
||||||
|
} else {
|
||||||
|
onChange([...values, v]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const customValue = search.trim();
|
||||||
|
const canAddCustom = customValue.length > 0 && !values.includes(customValue);
|
||||||
|
const triggerText =
|
||||||
|
values.length === 0
|
||||||
|
? placeholder || t("omo.selectPlaceholder", { defaultValue: "Select..." })
|
||||||
|
: values.length === 1
|
||||||
|
? values[0]
|
||||||
|
: `${values[0]} +${values.length - 1}`;
|
||||||
|
|
||||||
|
const availablePresets = presets?.filter(
|
||||||
|
(p) =>
|
||||||
|
!search.trim() ||
|
||||||
|
p.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
p.value.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
{values.length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5 text-xs text-muted-foreground"
|
||||||
|
onClick={() => onChange([])}
|
||||||
|
>
|
||||||
|
{t("omo.clear", { defaultValue: "Clear" })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{values.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{values.map((v, i) => (
|
||||||
|
<Badge
|
||||||
|
key={`${v}-${i}`}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs gap-1"
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
|
||||||
|
className="hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between w-full h-8 px-3 rounded-md border border-input bg-background text-sm",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||||
|
open && "ring-2 ring-ring",
|
||||||
|
)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"truncate",
|
||||||
|
values.length > 0 ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{triggerText}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
sideOffset={6}
|
||||||
|
className="w-[var(--radix-dropdown-menu-trigger-width)] p-0 z-[120]"
|
||||||
|
>
|
||||||
|
<div className="p-1.5 border-b border-border/30">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter" && canAddCustom) {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange([...values, customValue]);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
placeholder ||
|
||||||
|
t("omo.searchOrType", {
|
||||||
|
defaultValue: "Search or type custom value...",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-7 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canAddCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full px-2.5 py-1.5 text-left text-sm border-b border-border/30 hover:bg-accent"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
onChange([...values, customValue]);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ {customValue}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="max-h-48 overflow-auto py-1">
|
||||||
|
{availablePresets && availablePresets.length > 0 ? (
|
||||||
|
availablePresets.map((p) => {
|
||||||
|
const checked = values.includes(p.value);
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={p.value}
|
||||||
|
checked={checked}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
onCheckedChange={() => toggleValue(p.value)}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
||||||
|
{t("omo.noMatches", { defaultValue: "No matches" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonTextareaField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
minHeight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder || "{}"}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
style={{ minHeight: minHeight || "100px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OmoGlobalConfigFields = forwardRef<
|
||||||
|
OmoGlobalConfigFieldsRef,
|
||||||
|
OmoGlobalConfigFieldsProps
|
||||||
|
>(function OmoGlobalConfigFields({ onStateChange, hideSaveButtons }, ref) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: config } = useOmoGlobalConfig();
|
||||||
|
const saveMutation = useSaveOmoGlobalConfig();
|
||||||
|
|
||||||
|
const [schemaUrl, setSchemaUrl] = useState(OMO_DEFAULT_SCHEMA_URL);
|
||||||
|
const [sisyphusAgentStr, setSisyphusAgentStr] = useState("");
|
||||||
|
const [disabledAgents, setDisabledAgents] = useState<string[]>([]);
|
||||||
|
const [disabledMcps, setDisabledMcps] = useState<string[]>([]);
|
||||||
|
const [disabledHooks, setDisabledHooks] = useState<string[]>([]);
|
||||||
|
const [disabledSkills, setDisabledSkills] = useState<string[]>([]);
|
||||||
|
const [lspStr, setLspStr] = useState("");
|
||||||
|
const [experimentalStr, setExperimentalStr] = useState("");
|
||||||
|
const [backgroundTaskStr, setBackgroundTaskStr] = useState("");
|
||||||
|
const [browserStr, setBrowserStr] = useState("");
|
||||||
|
const [claudeCodeStr, setClaudeCodeStr] = useState("");
|
||||||
|
const [otherFieldsStr, setOtherFieldsStr] = useState("");
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const applyGlobalState = useCallback((global: OmoGlobalConfig) => {
|
||||||
|
setSchemaUrl(global.schemaUrl || OMO_DEFAULT_SCHEMA_URL);
|
||||||
|
setSisyphusAgentStr(
|
||||||
|
global.sisyphusAgent ? JSON.stringify(global.sisyphusAgent, null, 2) : "",
|
||||||
|
);
|
||||||
|
setDisabledAgents(global.disabledAgents || []);
|
||||||
|
setDisabledMcps(global.disabledMcps || []);
|
||||||
|
setDisabledHooks(global.disabledHooks || []);
|
||||||
|
setDisabledSkills(global.disabledSkills || []);
|
||||||
|
setLspStr(global.lsp ? JSON.stringify(global.lsp, null, 2) : "");
|
||||||
|
setExperimentalStr(
|
||||||
|
global.experimental ? JSON.stringify(global.experimental, null, 2) : "",
|
||||||
|
);
|
||||||
|
setBackgroundTaskStr(
|
||||||
|
global.backgroundTask
|
||||||
|
? JSON.stringify(global.backgroundTask, null, 2)
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
setBrowserStr(
|
||||||
|
global.browserAutomationEngine
|
||||||
|
? JSON.stringify(global.browserAutomationEngine, null, 2)
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
setClaudeCodeStr(
|
||||||
|
global.claudeCode ? JSON.stringify(global.claudeCode, null, 2) : "",
|
||||||
|
);
|
||||||
|
setOtherFieldsStr(
|
||||||
|
global.otherFields ? JSON.stringify(global.otherFields, null, 2) : "",
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config && !loaded) {
|
||||||
|
applyGlobalState(config);
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}, [config, loaded, applyGlobalState]);
|
||||||
|
|
||||||
|
const parseJsonField = useCallback(
|
||||||
|
(
|
||||||
|
fieldName: string,
|
||||||
|
raw: string,
|
||||||
|
strict: boolean,
|
||||||
|
): Record<string, unknown> | undefined => {
|
||||||
|
if (!raw.trim()) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
parsed === null ||
|
||||||
|
Array.isArray(parsed)
|
||||||
|
) {
|
||||||
|
if (strict) {
|
||||||
|
throw new Error(
|
||||||
|
t("omo.jsonMustBeObject", {
|
||||||
|
field: fieldName,
|
||||||
|
defaultValue: "{{field}} must be a JSON object",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch (error) {
|
||||||
|
if (strict) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
t("omo.jsonInvalid", {
|
||||||
|
field: fieldName,
|
||||||
|
defaultValue: "{{field}} contains invalid JSON",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildCurrentConfigInternal = useCallback(
|
||||||
|
(strict: boolean): OmoGlobalConfig => {
|
||||||
|
return {
|
||||||
|
id: "global",
|
||||||
|
schemaUrl: schemaUrl || undefined,
|
||||||
|
sisyphusAgent: parseJsonField(
|
||||||
|
t("omo.sisyphusAgentConfig", {
|
||||||
|
defaultValue: "Sisyphus Agent",
|
||||||
|
}),
|
||||||
|
sisyphusAgentStr,
|
||||||
|
strict,
|
||||||
|
),
|
||||||
|
disabledAgents,
|
||||||
|
disabledMcps,
|
||||||
|
disabledHooks,
|
||||||
|
disabledSkills,
|
||||||
|
lsp: parseJsonField(
|
||||||
|
t("omo.advancedLsp", { defaultValue: "LSP" }),
|
||||||
|
lspStr,
|
||||||
|
strict,
|
||||||
|
),
|
||||||
|
experimental: parseJsonField(
|
||||||
|
t("omo.advancedExperimental", { defaultValue: "Experimental" }),
|
||||||
|
experimentalStr,
|
||||||
|
strict,
|
||||||
|
),
|
||||||
|
backgroundTask: parseJsonField(
|
||||||
|
t("omo.advancedBackgroundTask", {
|
||||||
|
defaultValue: "Background Task",
|
||||||
|
}),
|
||||||
|
backgroundTaskStr,
|
||||||
|
strict,
|
||||||
|
),
|
||||||
|
browserAutomationEngine: parseJsonField(
|
||||||
|
t("omo.advancedBrowserAutomation", {
|
||||||
|
defaultValue: "Browser Automation",
|
||||||
|
}),
|
||||||
|
browserStr,
|
||||||
|
strict,
|
||||||
|
),
|
||||||
|
claudeCode: parseJsonField(
|
||||||
|
t("omo.advancedClaudeCode", { defaultValue: "Claude Code" }),
|
||||||
|
claudeCodeStr,
|
||||||
|
strict,
|
||||||
|
),
|
||||||
|
otherFields: parseJsonField(
|
||||||
|
t("omo.otherFields", {
|
||||||
|
defaultValue: "Other Config",
|
||||||
|
}),
|
||||||
|
otherFieldsStr,
|
||||||
|
strict,
|
||||||
|
),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[
|
||||||
|
schemaUrl,
|
||||||
|
sisyphusAgentStr,
|
||||||
|
disabledAgents,
|
||||||
|
disabledMcps,
|
||||||
|
disabledHooks,
|
||||||
|
disabledSkills,
|
||||||
|
lspStr,
|
||||||
|
experimentalStr,
|
||||||
|
backgroundTaskStr,
|
||||||
|
browserStr,
|
||||||
|
claudeCodeStr,
|
||||||
|
otherFieldsStr,
|
||||||
|
parseJsonField,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildCurrentConfig = useCallback(
|
||||||
|
() => buildCurrentConfigInternal(false),
|
||||||
|
[buildCurrentConfigInternal],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildCurrentConfigStrict = useCallback(
|
||||||
|
() => buildCurrentConfigInternal(true),
|
||||||
|
[buildCurrentConfigInternal],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loaded && onStateChange) {
|
||||||
|
onStateChange(buildCurrentConfig());
|
||||||
|
}
|
||||||
|
}, [loaded, onStateChange, buildCurrentConfig]);
|
||||||
|
|
||||||
|
const handleSaveGlobal = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = buildCurrentConfigStrict();
|
||||||
|
await saveMutation.mutateAsync(result);
|
||||||
|
toast.success(
|
||||||
|
t("omo.globalConfigSaved", {
|
||||||
|
defaultValue: "Global config saved",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(String(err));
|
||||||
|
}
|
||||||
|
}, [buildCurrentConfigStrict, saveMutation, t]);
|
||||||
|
|
||||||
|
const disabledCount =
|
||||||
|
disabledAgents.length +
|
||||||
|
disabledMcps.length +
|
||||||
|
disabledHooks.length +
|
||||||
|
disabledSkills.length;
|
||||||
|
const advancedFieldValues: Record<OmoAdvancedFieldKey, string> = {
|
||||||
|
lspStr,
|
||||||
|
experimentalStr,
|
||||||
|
backgroundTaskStr,
|
||||||
|
browserStr,
|
||||||
|
claudeCodeStr,
|
||||||
|
};
|
||||||
|
|
||||||
|
const advancedFieldSetters: Record<
|
||||||
|
OmoAdvancedFieldKey,
|
||||||
|
(value: string) => void
|
||||||
|
> = {
|
||||||
|
lspStr: setLspStr,
|
||||||
|
experimentalStr: setExperimentalStr,
|
||||||
|
backgroundTaskStr: setBackgroundTaskStr,
|
||||||
|
browserStr: setBrowserStr,
|
||||||
|
claudeCodeStr: setClaudeCodeStr,
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabledEditorConfigs = [
|
||||||
|
{
|
||||||
|
key: "agents",
|
||||||
|
label: t("omo.disabledAgents", { defaultValue: "Agents" }),
|
||||||
|
values: disabledAgents,
|
||||||
|
onChange: setDisabledAgents,
|
||||||
|
placeholder: t("omo.disabledAgentsPlaceholder", {
|
||||||
|
defaultValue: "Disabled Agents",
|
||||||
|
}),
|
||||||
|
presets: OMO_DISABLEABLE_AGENTS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mcps",
|
||||||
|
label: t("omo.disabledMcps", { defaultValue: "MCPs" }),
|
||||||
|
values: disabledMcps,
|
||||||
|
onChange: setDisabledMcps,
|
||||||
|
placeholder: t("omo.disabledMcpsPlaceholder", {
|
||||||
|
defaultValue: "Disabled MCPs",
|
||||||
|
}),
|
||||||
|
presets: OMO_DISABLEABLE_MCPS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hooks",
|
||||||
|
label: t("omo.disabledHooks", { defaultValue: "Hooks" }),
|
||||||
|
values: disabledHooks,
|
||||||
|
onChange: setDisabledHooks,
|
||||||
|
placeholder: t("omo.disabledHooksPlaceholder", {
|
||||||
|
defaultValue: "Disabled Hooks",
|
||||||
|
}),
|
||||||
|
presets: OMO_DISABLEABLE_HOOKS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "skills",
|
||||||
|
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
|
||||||
|
values: disabledSkills,
|
||||||
|
onChange: setDisabledSkills,
|
||||||
|
placeholder: t("omo.disabledSkillsPlaceholder", {
|
||||||
|
defaultValue: "Disabled Skills",
|
||||||
|
}),
|
||||||
|
presets: OMO_DISABLEABLE_SKILLS,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const readLocalFile = useReadOmoLocalFile();
|
||||||
|
|
||||||
|
const handleImportGlobalFromLocal = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await readLocalFile.mutateAsync();
|
||||||
|
applyGlobalState(data.global);
|
||||||
|
toast.success(
|
||||||
|
t("omo.importGlobalSuccess", {
|
||||||
|
defaultValue: "Imported global config from local file (unsaved)",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
t("omo.importGlobalFailed", {
|
||||||
|
error: String(err),
|
||||||
|
defaultValue: "Failed to read local file: {{error}}",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [readLocalFile, applyGlobalState, t]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
buildCurrentConfig,
|
||||||
|
buildCurrentConfigStrict,
|
||||||
|
importFromLocal: handleImportGlobalFromLocal,
|
||||||
|
}),
|
||||||
|
[buildCurrentConfig, buildCurrentConfigStrict, handleImportGlobalFromLocal],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!hideSaveButtons && (
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={readLocalFile.isPending}
|
||||||
|
onClick={handleImportGlobalFromLocal}
|
||||||
|
>
|
||||||
|
{readLocalFile.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FolderInput className="h-3.5 w-3.5 mr-1" />
|
||||||
|
)}
|
||||||
|
{t("omo.importLocal", { defaultValue: "Import Local" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
onClick={handleSaveGlobal}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3.5 w-3.5 mr-1" />
|
||||||
|
)}
|
||||||
|
{t("omo.saveGlobalConfig", { defaultValue: "Save Global Config" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm">
|
||||||
|
{t("omo.schemaUrl", { defaultValue: "$schema" })}
|
||||||
|
</Label>
|
||||||
|
{schemaUrl !== OMO_DEFAULT_SCHEMA_URL && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs px-1.5"
|
||||||
|
onClick={() => setSchemaUrl(OMO_DEFAULT_SCHEMA_URL)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3 mr-0.5" />
|
||||||
|
{t("omo.resetDefault", { defaultValue: "Reset" })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={schemaUrl}
|
||||||
|
onChange={(e) => setSchemaUrl(e.target.value)}
|
||||||
|
placeholder={OMO_DEFAULT_SCHEMA_URL}
|
||||||
|
className="text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t("omo.sisyphusAgentConfig", {
|
||||||
|
defaultValue: "Sisyphus Agent",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={sisyphusAgentStr}
|
||||||
|
onChange={(e) => setSisyphusAgentStr(e.target.value)}
|
||||||
|
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
style={{ minHeight: "140px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t("omo.disabledItems", { defaultValue: "Disabled Items" })}
|
||||||
|
</Label>
|
||||||
|
{disabledCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs h-5">
|
||||||
|
{disabledCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{disabledEditorConfigs.map((editor) => (
|
||||||
|
<TagListEditor
|
||||||
|
key={editor.key}
|
||||||
|
label={editor.label}
|
||||||
|
values={editor.values}
|
||||||
|
onChange={editor.onChange}
|
||||||
|
placeholder={editor.placeholder}
|
||||||
|
presets={editor.presets}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t("omo.advanced", { defaultValue: "Advanced Settings" })}
|
||||||
|
</Label>
|
||||||
|
{OMO_ADVANCED_JSON_FIELDS.map((field) => (
|
||||||
|
<JsonTextareaField
|
||||||
|
key={field.key}
|
||||||
|
label={t(field.labelKey, { defaultValue: field.defaultLabel })}
|
||||||
|
value={advancedFieldValues[field.key]}
|
||||||
|
onChange={advancedFieldSetters[field.key]}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
minHeight={field.minHeight}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<JsonTextareaField
|
||||||
|
label={t("omo.otherFields", {
|
||||||
|
defaultValue: "Other Config",
|
||||||
|
})}
|
||||||
|
value={otherFieldsStr}
|
||||||
|
onChange={setOtherFieldsStr}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -139,6 +139,8 @@ interface OpenCodeFormFieldsProps {
|
|||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
shouldShowApiKeyLink: boolean;
|
shouldShowApiKeyLink: boolean;
|
||||||
websiteUrl: string;
|
websiteUrl: string;
|
||||||
|
isPartner?: boolean;
|
||||||
|
partnerPromotionKey?: string;
|
||||||
|
|
||||||
// Base URL
|
// Base URL
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@@ -161,6 +163,8 @@ export function OpenCodeFormFields({
|
|||||||
category,
|
category,
|
||||||
shouldShowApiKeyLink,
|
shouldShowApiKeyLink,
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
|
isPartner,
|
||||||
|
partnerPromotionKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
onBaseUrlChange,
|
onBaseUrlChange,
|
||||||
models,
|
models,
|
||||||
@@ -376,6 +380,8 @@ export function OpenCodeFormFields({
|
|||||||
category={category}
|
category={category}
|
||||||
shouldShowLink={shouldShowApiKeyLink}
|
shouldShowLink={shouldShowApiKeyLink}
|
||||||
websiteUrl={websiteUrl}
|
websiteUrl={websiteUrl}
|
||||||
|
isPartner={isPartner}
|
||||||
|
partnerPromotionKey={partnerPromotionKey}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Base URL */}
|
{/* Base URL */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Globe,
|
Globe,
|
||||||
|
Coins,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
X,
|
X,
|
||||||
@@ -13,14 +14,31 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types";
|
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types";
|
||||||
|
|
||||||
|
export type PricingModelSourceOption = "inherit" | "request" | "response";
|
||||||
|
|
||||||
|
interface ProviderPricingConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
costMultiplier?: string;
|
||||||
|
pricingModelSource: PricingModelSourceOption;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProviderAdvancedConfigProps {
|
interface ProviderAdvancedConfigProps {
|
||||||
testConfig: ProviderTestConfig;
|
testConfig: ProviderTestConfig;
|
||||||
proxyConfig: ProviderProxyConfig;
|
proxyConfig: ProviderProxyConfig;
|
||||||
|
pricingConfig: ProviderPricingConfig;
|
||||||
onTestConfigChange: (config: ProviderTestConfig) => void;
|
onTestConfigChange: (config: ProviderTestConfig) => void;
|
||||||
onProxyConfigChange: (config: ProviderProxyConfig) => void;
|
onProxyConfigChange: (config: ProviderProxyConfig) => void;
|
||||||
|
onPricingConfigChange: (config: ProviderPricingConfig) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 ProviderProxyConfig 构建完整 URL */
|
/** 从 ProviderProxyConfig 构建完整 URL */
|
||||||
@@ -71,14 +89,19 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
|
|||||||
export function ProviderAdvancedConfig({
|
export function ProviderAdvancedConfig({
|
||||||
testConfig,
|
testConfig,
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
|
pricingConfig,
|
||||||
onTestConfigChange,
|
onTestConfigChange,
|
||||||
onProxyConfigChange,
|
onProxyConfigChange,
|
||||||
|
onPricingConfigChange,
|
||||||
}: ProviderAdvancedConfigProps) {
|
}: ProviderAdvancedConfigProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||||
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
||||||
proxyConfig.enabled,
|
proxyConfig.enabled,
|
||||||
);
|
);
|
||||||
|
const [isPricingConfigOpen, setIsPricingConfigOpen] = useState(
|
||||||
|
pricingConfig.enabled,
|
||||||
|
);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
// 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建)
|
// 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建)
|
||||||
@@ -97,6 +120,11 @@ export function ProviderAdvancedConfig({
|
|||||||
setIsProxyConfigOpen(proxyConfig.enabled);
|
setIsProxyConfigOpen(proxyConfig.enabled);
|
||||||
}, [proxyConfig.enabled]);
|
}, [proxyConfig.enabled]);
|
||||||
|
|
||||||
|
// 同步外部 pricingConfig.enabled 变化到展开状态
|
||||||
|
useEffect(() => {
|
||||||
|
setIsPricingConfigOpen(pricingConfig.enabled);
|
||||||
|
}, [pricingConfig.enabled]);
|
||||||
|
|
||||||
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
|
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUserTyping) {
|
if (!isUserTyping) {
|
||||||
@@ -450,6 +478,143 @@ export function ProviderAdvancedConfig({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 计费配置 */}
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
|
||||||
|
onClick={() => setIsPricingConfigOpen(!isPricingConfigOpen)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Coins className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("providerAdvanced.pricingConfig", {
|
||||||
|
defaultValue: "计费配置",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
htmlFor="pricing-config-enabled"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("providerAdvanced.useCustomPricing", {
|
||||||
|
defaultValue: "使用单独配置",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="pricing-config-enabled"
|
||||||
|
checked={pricingConfig.enabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onPricingConfigChange({ ...pricingConfig, enabled: checked });
|
||||||
|
if (checked) setIsPricingConfigOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isPricingConfigOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden transition-all duration-200",
|
||||||
|
isPricingConfigOpen
|
||||||
|
? "max-h-[500px] opacity-100"
|
||||||
|
: "max-h-0 opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="border-t border-border/50 p-4 space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("providerAdvanced.pricingConfigDesc", {
|
||||||
|
defaultValue:
|
||||||
|
"为此供应商配置单独的计费参数,不启用时使用全局默认配置。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cost-multiplier">
|
||||||
|
{t("providerAdvanced.costMultiplier", {
|
||||||
|
defaultValue: "成本倍率",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="cost-multiplier"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={pricingConfig.costMultiplier || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPricingConfigChange({
|
||||||
|
...pricingConfig,
|
||||||
|
costMultiplier: e.target.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t("providerAdvanced.costMultiplierPlaceholder", {
|
||||||
|
defaultValue: "留空使用全局默认(1)",
|
||||||
|
})}
|
||||||
|
disabled={!pricingConfig.enabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerAdvanced.costMultiplierHint", {
|
||||||
|
defaultValue: "实际成本 = 基础成本 × 倍率,支持小数如 1.5",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="pricing-model-source">
|
||||||
|
{t("providerAdvanced.pricingModelSourceLabel", {
|
||||||
|
defaultValue: "计费模式",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={pricingConfig.pricingModelSource}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onPricingConfigChange({
|
||||||
|
...pricingConfig,
|
||||||
|
pricingModelSource: value as PricingModelSourceOption,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!pricingConfig.enabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="pricing-model-source">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="inherit">
|
||||||
|
{t("providerAdvanced.pricingModelSourceInherit", {
|
||||||
|
defaultValue: "继承全局默认",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="request">
|
||||||
|
{t("providerAdvanced.pricingModelSourceRequest", {
|
||||||
|
defaultValue: "请求模型",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="response">
|
||||||
|
{t("providerAdvanced.pricingModelSourceResponse", {
|
||||||
|
defaultValue: "返回模型",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerAdvanced.pricingModelSourceHint", {
|
||||||
|
defaultValue: "选择按请求模型还是返回模型进行定价匹配",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,6 @@ export function ProviderPresetSelector({
|
|||||||
}: ProviderPresetSelectorProps) {
|
}: ProviderPresetSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 根据分类获取提示文字
|
|
||||||
const getCategoryHint = (): React.ReactNode => {
|
const getCategoryHint = (): React.ReactNode => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case "official":
|
case "official":
|
||||||
@@ -63,6 +62,11 @@ export function ProviderPresetSelector({
|
|||||||
return t("providerForm.customApiKeyHint", {
|
return t("providerForm.customApiKeyHint", {
|
||||||
defaultValue: "💡 自定义配置需手动填写所有必要字段",
|
defaultValue: "💡 自定义配置需手动填写所有必要字段",
|
||||||
});
|
});
|
||||||
|
case "omo":
|
||||||
|
return t("providerForm.omoHint", {
|
||||||
|
defaultValue:
|
||||||
|
"💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
|
||||||
|
});
|
||||||
default:
|
default:
|
||||||
return t("providerPreset.hint", {
|
return t("providerPreset.hint", {
|
||||||
defaultValue: "选择预设后可继续调整下方字段。",
|
defaultValue: "选择预设后可继续调整下方字段。",
|
||||||
@@ -70,7 +74,6 @@ export function ProviderPresetSelector({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染预设按钮的图标
|
|
||||||
const renderPresetIcon = (
|
const renderPresetIcon = (
|
||||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
||||||
) => {
|
) => {
|
||||||
@@ -91,7 +94,6 @@ export function ProviderPresetSelector({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取预设按钮的样式类名
|
|
||||||
const getPresetButtonClass = (
|
const getPresetButtonClass = (
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
||||||
@@ -100,18 +102,15 @@ export function ProviderPresetSelector({
|
|||||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
// 如果有自定义主题,使用自定义颜色
|
|
||||||
if (preset.theme?.backgroundColor) {
|
if (preset.theme?.backgroundColor) {
|
||||||
return `${baseClass} text-white`;
|
return `${baseClass} text-white`;
|
||||||
}
|
}
|
||||||
// 默认使用主题蓝色
|
|
||||||
return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;
|
return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${baseClass} bg-accent text-muted-foreground hover:bg-accent/80`;
|
return `${baseClass} bg-accent text-muted-foreground hover:bg-accent/80`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取预设按钮的内联样式(用于自定义背景色)
|
|
||||||
const getPresetButtonStyle = (
|
const getPresetButtonStyle = (
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
||||||
@@ -130,7 +129,6 @@ export function ProviderPresetSelector({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<FormLabel>{t("providerPreset.label")}</FormLabel>
|
<FormLabel>{t("providerPreset.label")}</FormLabel>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{/* 自定义按钮 */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPresetChange("custom")}
|
onClick={() => onPresetChange("custom")}
|
||||||
@@ -143,7 +141,6 @@ export function ProviderPresetSelector({
|
|||||||
{t("providerPreset.custom")}
|
{t("providerPreset.custom")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 预设按钮 */}
|
|
||||||
{categoryKeys.map((category) => {
|
{categoryKeys.map((category) => {
|
||||||
const entries = groupedPresets[category];
|
const entries = groupedPresets[category];
|
||||||
if (!entries || entries.length === 0) return null;
|
if (!entries || entries.length === 0) return null;
|
||||||
@@ -174,7 +171,6 @@ export function ProviderPresetSelector({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统一供应商预设(新的一行) */}
|
|
||||||
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
|
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -196,7 +192,6 @@ export function ProviderPresetSelector({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{/* 管理统一供应商按钮 */}
|
|
||||||
{onManageUniversalProviders && (
|
{onManageUniversalProviders && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import type { ProviderCategory } from "@/types";
|
|||||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||||
|
import type { OpenCodeProviderPreset } from "@/config/opencodeProviderPresets";
|
||||||
|
|
||||||
type PresetEntry = {
|
type PresetEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
|
preset:
|
||||||
|
| ProviderPreset
|
||||||
|
| CodexProviderPreset
|
||||||
|
| GeminiProviderPreset
|
||||||
|
| OpenCodeProviderPreset;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UseApiKeyLinkProps {
|
interface UseApiKeyLinkProps {
|
||||||
@@ -74,7 +79,10 @@ export function useApiKeyLink({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
shouldShowApiKeyLink:
|
shouldShowApiKeyLink:
|
||||||
appId === "claude" || appId === "codex" || appId === "gemini"
|
appId === "claude" ||
|
||||||
|
appId === "codex" ||
|
||||||
|
appId === "gemini" ||
|
||||||
|
appId === "opencode"
|
||||||
? shouldShowApiKeyLink
|
? shouldShowApiKeyLink
|
||||||
: false,
|
: false,
|
||||||
websiteUrl: getWebsiteUrl,
|
websiteUrl: getWebsiteUrl,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { AppId } from "@/lib/api";
|
|||||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||||
|
import { opencodeProviderPresets } from "@/config/opencodeProviderPresets";
|
||||||
|
|
||||||
interface UseProviderCategoryProps {
|
interface UseProviderCategoryProps {
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
@@ -42,7 +43,9 @@ export function useProviderCategory({
|
|||||||
if (!selectedPresetId) return;
|
if (!selectedPresetId) return;
|
||||||
|
|
||||||
// 从预设 ID 提取索引
|
// 从预设 ID 提取索引
|
||||||
const match = selectedPresetId.match(/^(claude|codex|gemini)-(\d+)$/);
|
const match = selectedPresetId.match(
|
||||||
|
/^(claude|codex|gemini|opencode)-(\d+)$/,
|
||||||
|
);
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
const [, type, indexStr] = match;
|
const [, type, indexStr] = match;
|
||||||
@@ -67,6 +70,11 @@ export function useProviderCategory({
|
|||||||
if (preset) {
|
if (preset) {
|
||||||
setCategory(preset.category || undefined);
|
setCategory(preset.category || undefined);
|
||||||
}
|
}
|
||||||
|
} else if (type === "opencode" && appId === "opencode") {
|
||||||
|
const preset = opencodeProviderPresets[index];
|
||||||
|
if (preset) {
|
||||||
|
setCategory(preset.category || undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [appId, selectedPresetId, isEditMode, initialCategory]);
|
}, [appId, selectedPresetId, isEditMode, initialCategory]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { ChevronRight, Clock } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
|
import type { SessionMeta } from "@/types";
|
||||||
|
import {
|
||||||
|
formatRelativeTime,
|
||||||
|
formatSessionTitle,
|
||||||
|
getProviderIconName,
|
||||||
|
getProviderLabel,
|
||||||
|
getSessionKey,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
interface SessionItemProps {
|
||||||
|
session: SessionMeta;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionItem({
|
||||||
|
session,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: SessionItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const title = formatSessionTitle(session);
|
||||||
|
const lastActive = session.lastActiveAt || session.createdAt || undefined;
|
||||||
|
const sessionKey = getSessionKey(session);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(sessionKey)}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/10 border border-primary/30"
|
||||||
|
: "hover:bg-muted/60 border border-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="shrink-0">
|
||||||
|
<ProviderIcon
|
||||||
|
icon={getProviderIconName(session.providerId)}
|
||||||
|
name={session.providerId}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{getProviderLabel(session.providerId, t)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||||
|
isSelected && "text-primary rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
<Clock className="size-3" />
|
||||||
|
<span>
|
||||||
|
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Play,
|
||||||
|
MessageSquare,
|
||||||
|
Clock,
|
||||||
|
FolderOpen,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useSessionMessagesQuery, useSessionsQuery } from "@/lib/query";
|
||||||
|
import { sessionsApi } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
import { isMac } from "@/lib/platform";
|
||||||
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
|
import { SessionItem } from "./SessionItem";
|
||||||
|
import { SessionMessageItem } from "./SessionMessageItem";
|
||||||
|
import { SessionTocDialog, SessionTocSidebar } from "./SessionToc";
|
||||||
|
import {
|
||||||
|
formatSessionTitle,
|
||||||
|
formatTimestamp,
|
||||||
|
getBaseName,
|
||||||
|
getProviderIconName,
|
||||||
|
getProviderLabel,
|
||||||
|
getSessionKey,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
type ProviderFilter = "all" | "codex" | "claude";
|
||||||
|
|
||||||
|
export function SessionManagerPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, refetch } = useSessionsQuery();
|
||||||
|
const sessions = data ?? [];
|
||||||
|
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 使用 FlexSearch 全文搜索
|
||||||
|
const { search: searchSessions } = useSessionSearch({
|
||||||
|
sessions,
|
||||||
|
providerFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredSessions = useMemo(() => {
|
||||||
|
return searchSessions(search);
|
||||||
|
}, [searchSessions, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredSessions.length === 0) {
|
||||||
|
setSelectedKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exists = selectedKey
|
||||||
|
? filteredSessions.some(
|
||||||
|
(session) => getSessionKey(session) === selectedKey,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
if (!exists) {
|
||||||
|
setSelectedKey(getSessionKey(filteredSessions[0]));
|
||||||
|
}
|
||||||
|
}, [filteredSessions, selectedKey]);
|
||||||
|
|
||||||
|
const selectedSession = useMemo(() => {
|
||||||
|
if (!selectedKey) return null;
|
||||||
|
return (
|
||||||
|
filteredSessions.find(
|
||||||
|
(session) => getSessionKey(session) === selectedKey,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [filteredSessions, selectedKey]);
|
||||||
|
|
||||||
|
const { data: messages = [], isLoading: isLoadingMessages } =
|
||||||
|
useSessionMessagesQuery(
|
||||||
|
selectedSession?.providerId,
|
||||||
|
selectedSession?.sourcePath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 提取用户消息用于目录
|
||||||
|
const userMessagesToc = useMemo(() => {
|
||||||
|
return messages
|
||||||
|
.map((msg, index) => ({ msg, index }))
|
||||||
|
.filter(({ msg }) => msg.role.toLowerCase() === "user")
|
||||||
|
.map(({ msg, index }) => ({
|
||||||
|
index,
|
||||||
|
preview:
|
||||||
|
msg.content.slice(0, 50) + (msg.content.length > 50 ? "..." : ""),
|
||||||
|
ts: msg.ts,
|
||||||
|
}));
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const scrollToMessage = (index: number) => {
|
||||||
|
const el = messageRefs.current.get(index);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
setActiveMessageIndex(index);
|
||||||
|
setTocDialogOpen(false); // 关闭弹窗
|
||||||
|
// 清除高亮状态
|
||||||
|
setTimeout(() => setActiveMessageIndex(null), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// 这里的 setTimeout 其实无法直接清理,因为它在函数闭包里。
|
||||||
|
// 如果要严格清理,需要用 useRef 存 timer id。
|
||||||
|
// 但对于 2秒的高亮清除,通常不清理也没大问题。
|
||||||
|
// 为了代码规范,我们在组件卸载时将 activeMessageIndex 重置 (虽然 React 会处理)
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = async (text: string, successMessage: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(successMessage);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
extractErrorMessage(error) ||
|
||||||
|
t("common.error", { defaultValue: "Copy failed" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResume = async () => {
|
||||||
|
if (!selectedSession?.resumeCommand) return;
|
||||||
|
|
||||||
|
if (!isMac()) {
|
||||||
|
await handleCopy(
|
||||||
|
selectedSession.resumeCommand,
|
||||||
|
t("sessionManager.resumeCommandCopied"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsApi.launchTerminal({
|
||||||
|
command: selectedSession.resumeCommand,
|
||||||
|
cwd: selectedSession.projectDir ?? undefined,
|
||||||
|
});
|
||||||
|
toast.success(t("sessionManager.terminalLaunched"));
|
||||||
|
} catch (error) {
|
||||||
|
const fallback = selectedSession.resumeCommand;
|
||||||
|
await handleCopy(fallback, t("sessionManager.resumeFallbackCopied"));
|
||||||
|
toast.error(extractErrorMessage(error) || t("sessionManager.openFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="mx-auto px-4 sm:px-6 flex flex-col h-[calc(100vh-8rem)]">
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col gap-4">
|
||||||
|
{/* 主内容区域 - 左右分栏 */}
|
||||||
|
<div className="flex-1 overflow-hidden grid gap-4 md:grid-cols-[320px_1fr]">
|
||||||
|
{/* 左侧会话列表 */}
|
||||||
|
<Card className="flex flex-col overflow-hidden">
|
||||||
|
<CardHeader className="py-2 px-3 border-b">
|
||||||
|
{isSearchOpen ? (
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder={t("sessionManager.searchPlaceholder")}
|
||||||
|
className="h-8 pl-8 pr-8 text-sm"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (search.trim() === "") {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t("sessionManager.sessionList")}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{filteredSessions.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
setTimeout(
|
||||||
|
() => searchInputRef.current?.focus(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("sessionManager.searchSessions")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={providerFilter}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setProviderFilter(value as ProviderFilter)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||||
|
<ProviderIcon
|
||||||
|
icon={
|
||||||
|
providerFilter === "all"
|
||||||
|
? "apps"
|
||||||
|
: providerFilter === "codex"
|
||||||
|
? "openai"
|
||||||
|
: "claude"
|
||||||
|
}
|
||||||
|
name={providerFilter}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{providerFilter === "all"
|
||||||
|
? t("sessionManager.providerFilterAll")
|
||||||
|
: providerFilter}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon icon="apps" name="all" size={14} />
|
||||||
|
<span>
|
||||||
|
{t("sessionManager.providerFilterAll")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="codex">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="openai"
|
||||||
|
name="codex"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>Codex</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="claude">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="claude"
|
||||||
|
name="claude"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>Claude Code</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 overflow-hidden p-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : filteredSessions.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<MessageSquare className="size-8 text-muted-foreground/50 mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("sessionManager.noSessions")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredSessions.map((session) => {
|
||||||
|
const isSelected =
|
||||||
|
selectedKey !== null &&
|
||||||
|
getSessionKey(session) === selectedKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionItem
|
||||||
|
key={getSessionKey(session)}
|
||||||
|
session={session}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelect={setSelectedKey}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 右侧会话详情 */}
|
||||||
|
<Card
|
||||||
|
className="flex flex-col overflow-hidden min-h-0"
|
||||||
|
ref={detailRef}
|
||||||
|
>
|
||||||
|
{!selectedSession ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8">
|
||||||
|
<MessageSquare className="size-12 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">{t("sessionManager.selectSession")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 详情头部 */}
|
||||||
|
<CardHeader className="py-3 px-4 border-b shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
{/* 左侧:会话信息 */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="shrink-0">
|
||||||
|
<ProviderIcon
|
||||||
|
icon={getProviderIconName(
|
||||||
|
selectedSession.providerId,
|
||||||
|
)}
|
||||||
|
name={selectedSession.providerId}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{getProviderLabel(selectedSession.providerId, t)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<h2 className="text-base font-semibold truncate">
|
||||||
|
{formatSessionTitle(selectedSession)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 元信息 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="size-3" />
|
||||||
|
<span>
|
||||||
|
{formatTimestamp(
|
||||||
|
selectedSession.lastActiveAt ??
|
||||||
|
selectedSession.createdAt,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selectedSession.projectDir && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void handleCopy(
|
||||||
|
selectedSession.projectDir!,
|
||||||
|
t("sessionManager.projectDirCopied"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<FolderOpen className="size-3" />
|
||||||
|
<span className="truncate max-w-[200px]">
|
||||||
|
{getBaseName(selectedSession.projectDir)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
className="max-w-xs"
|
||||||
|
>
|
||||||
|
<p className="font-mono text-xs break-all">
|
||||||
|
{selectedSession.projectDir}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{t("sessionManager.clickToCopyPath")}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:操作按钮组 */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{isMac() && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => void handleResume()}
|
||||||
|
disabled={!selectedSession.resumeCommand}
|
||||||
|
>
|
||||||
|
<Play className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{t("sessionManager.resume", {
|
||||||
|
defaultValue: "恢复会话",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{selectedSession.resumeCommand
|
||||||
|
? t("sessionManager.resumeTooltip", {
|
||||||
|
defaultValue: "在终端中恢复此会话",
|
||||||
|
})
|
||||||
|
: t("sessionManager.noResumeCommand", {
|
||||||
|
defaultValue: "此会话无法恢复",
|
||||||
|
})}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 恢复命令预览 */}
|
||||||
|
{selectedSession.resumeCommand && (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<div className="flex-1 rounded-md bg-muted/60 px-3 py-1.5 font-mono text-xs text-muted-foreground truncate">
|
||||||
|
{selectedSession.resumeCommand}
|
||||||
|
</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
onClick={() =>
|
||||||
|
void handleCopy(
|
||||||
|
selectedSession.resumeCommand!,
|
||||||
|
t("sessionManager.resumeCommandCopied"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("sessionManager.copyCommand", {
|
||||||
|
defaultValue: "复制命令",
|
||||||
|
})}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* 消息列表区域 */}
|
||||||
|
<CardContent className="flex-1 overflow-hidden p-0">
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* 消息列表 */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<MessageSquare className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("sessionManager.conversationHistory", {
|
||||||
|
defaultValue: "对话记录",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{messages.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingMessages ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<MessageSquare className="size-8 text-muted-foreground/50 mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("sessionManager.emptySession")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<SessionMessageItem
|
||||||
|
key={`${message.role}-${index}`}
|
||||||
|
message={message}
|
||||||
|
index={index}
|
||||||
|
isActive={activeMessageIndex === index}
|
||||||
|
setRef={(el) => {
|
||||||
|
if (el) messageRefs.current.set(index, el);
|
||||||
|
}}
|
||||||
|
onCopy={(content) =>
|
||||||
|
handleCopy(
|
||||||
|
content,
|
||||||
|
t("sessionManager.messageCopied", {
|
||||||
|
defaultValue: "已复制消息内容",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 右侧目录 - 类似少数派 (大屏幕) */}
|
||||||
|
<SessionTocSidebar
|
||||||
|
items={userMessagesToc}
|
||||||
|
onItemClick={scrollToMessage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 浮动目录按钮 (小屏幕) */}
|
||||||
|
<SessionTocDialog
|
||||||
|
items={userMessagesToc}
|
||||||
|
onItemClick={scrollToMessage}
|
||||||
|
open={tocDialogOpen}
|
||||||
|
onOpenChange={setTocDialogOpen}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user