mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -20,6 +20,8 @@ jobs:
|
||||
include:
|
||||
- os: windows-2022
|
||||
- os: ubuntu-22.04
|
||||
- os: ubuntu-22.04-arm
|
||||
arch: arm64
|
||||
- os: macos-14
|
||||
|
||||
steps:
|
||||
@@ -57,7 +59,8 @@ jobs:
|
||||
rpm \
|
||||
flatpak \
|
||||
flatpak-builder \
|
||||
elfutils
|
||||
elfutils \
|
||||
xdg-utils
|
||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev \
|
||||
@@ -85,8 +88,8 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-pnpm-store-
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
|
||||
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -256,10 +259,11 @@ jobs:
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
||||
ARCH="${{ matrix.arch || 'x86_64' }}"
|
||||
# Updater artifact: AppImage(含对应 .sig)
|
||||
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
||||
if [ -n "$APPIMAGE" ]; then
|
||||
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
|
||||
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux-${ARCH}.AppImage"
|
||||
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
|
||||
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
|
||||
echo "AppImage copied: $NEW_APPIMAGE"
|
||||
@@ -269,18 +273,16 @@ jobs:
|
||||
# 额外上传 .deb(用于手动安装,不参与 Updater)
|
||||
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
||||
if [ -n "$DEB" ]; then
|
||||
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
|
||||
cp "$DEB" "release-assets/$NEW_DEB"
|
||||
echo "Deb package copied: $NEW_DEB"
|
||||
cp "$DEB" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.deb"
|
||||
echo "Deb package copied: CC-Switch-${VERSION}-Linux-${ARCH}.deb"
|
||||
else
|
||||
echo "No .deb found (optional)"
|
||||
fi
|
||||
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater)
|
||||
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
|
||||
if [ -n "$RPM" ]; then
|
||||
NEW_RPM="CC-Switch-${VERSION}-Linux.rpm"
|
||||
cp "$RPM" "release-assets/$NEW_RPM"
|
||||
echo "RPM package copied: $NEW_RPM"
|
||||
cp "$RPM" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
|
||||
echo "RPM package copied: CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
|
||||
else
|
||||
echo "No .rpm found (optional)"
|
||||
fi
|
||||
@@ -312,7 +314,8 @@ jobs:
|
||||
|
||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
||||
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||
- **Linux**: `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"`
|
||||
@@ -360,7 +363,8 @@ jobs:
|
||||
# 初始化空平台映射
|
||||
mac_url=""; mac_sig=""
|
||||
win_url=""; win_sig=""
|
||||
linux_url=""; linux_sig=""
|
||||
linux_x64_url=""; linux_x64_sig=""
|
||||
linux_arm64_url=""; linux_arm64_sig=""
|
||||
shopt -s nullglob
|
||||
for sig in dl/*.sig; do
|
||||
base=${sig%.sig}
|
||||
@@ -371,8 +375,10 @@ jobs:
|
||||
*.tar.gz)
|
||||
# 视为 macOS updater artifact
|
||||
mac_url="$url"; mac_sig="$sig_content";;
|
||||
*.AppImage|*.appimage)
|
||||
linux_url="$url"; linux_sig="$sig_content";;
|
||||
*-Linux-arm64.AppImage|*-Linux-arm64.appimage)
|
||||
linux_arm64_url="$url"; linux_arm64_sig="$sig_content";;
|
||||
*-Linux-x86_64.AppImage|*-Linux-x86_64.appimage)
|
||||
linux_x64_url="$url"; linux_x64_sig="$sig_content";;
|
||||
*.msi|*.exe)
|
||||
win_url="$url"; win_sig="$sig_content";;
|
||||
esac
|
||||
@@ -399,9 +405,14 @@ jobs:
|
||||
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
|
||||
first=0
|
||||
fi
|
||||
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
|
||||
if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then
|
||||
[ $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
|
||||
fi
|
||||
echo ' }'
|
||||
|
||||
@@ -9,6 +9,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [3.10.3] - 2026-01-30
|
||||
|
||||
### Feature Release
|
||||
|
||||
This release introduces a generic API format selector, pricing configuration enhancements, and multiple UX improvements.
|
||||
|
||||
### Added
|
||||
|
||||
- **API Key Link for OpenCode**: API key link support for OpenCode provider form, enabling quick access to provider key management pages
|
||||
- **AICodeMirror Partner Preset**: Added AICodeMirror partner preset for all apps (Claude, Codex, Gemini, OpenCode)
|
||||
- **API Format Selector**: Generic API format chooser for Claude providers, replacing the OpenRouter-specific toggle. Supports Anthropic Messages (native) and OpenAI Chat Completions format
|
||||
- **API Format Presets**: Allow preset providers to specify API format (anthropic or openai_chat) for third-party proxy services
|
||||
- **Proxy Hint**: Display info toast when switching to OpenAI Chat format provider, reminding users to enable proxy
|
||||
- **Pricing Config Enhancement**: Per-provider cost multiplier, pricing model source (request/response), request model logging, and enriched usage UI (#781)
|
||||
- **Skills ZIP Install**: Install skills directly from local ZIP files with recursive scanning support
|
||||
- **Preferred Terminal**: Choose preferred terminal app per platform (macOS: Terminal.app/iTerm2/Alacritty/Kitty/Ghostty; Windows: cmd/PowerShell/Windows Terminal; Linux: GNOME Terminal/Konsole/Xfce4/Alacritty/Kitty/Ghostty)
|
||||
- **Silent Startup**: Option to prevent window popup on launch (#713)
|
||||
- **OpenCode Environment Check**: Version detection with Go path scanning and one-click install from GitHub Releases
|
||||
- **OpenCode Directory Sync**: Auto-sync all providers to live config on directory change with additive mode support
|
||||
- **NVIDIA NIM Preset**: New provider preset for Claude and OpenCode with nvidia.svg icon
|
||||
- **n1n.ai Preset**: New provider preset (#667)
|
||||
- **Update Badge Icon**: Replace update badge dot with ArrowUpCircle icon
|
||||
- **Linux ARM64**: CI build support for Linux ARM64 architecture
|
||||
|
||||
### Changed
|
||||
|
||||
- **API Format Migration**: Migrate api_format from settings_config to ProviderMeta to prevent polluting ~/.claude/settings.json
|
||||
- **DeepSeek max_tokens**: Remove max_tokens clamp from proxy transform layer
|
||||
- **Terminal Functions**: Consolidate redundant terminal launch functions
|
||||
- **Home Dir Utility**: Consolidate get_home_dir into single public function
|
||||
- **Kimi/Moonshot**: Upgrade provider presets to k2.5 model
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex 404 & Timeout**: Fix 404 errors and connection timeout with custom base_url; improve /v1 prefix handling and system proxy detection (#760)
|
||||
- **Proxy URL Building**: Fix duplicate /v1/v1 in URL; extend ?beta=true to /v1/chat/completions endpoint
|
||||
- **OpenRouter Compat Mode**: Improve backward compatibility supporting number and string types
|
||||
- **Gemini Visibility**: Correct Gemini default visibility to true (#818)
|
||||
- **Footer Layout**: Correct footer layout in advanced settings tab
|
||||
- **Claude Code Detection**: Prioritize native install path for detection
|
||||
- **Tray Menu**: Simplify title labels and optimize menu separators (#796)
|
||||
- **Duplicate Skills**: Prevent duplicate skill installation from different repos (#778)
|
||||
- **Windows Tests**: Stabilize test environment (#644)
|
||||
- **i18n**: Update apiFormatOpenAIChat label to mention proxy requirement
|
||||
- **Error Display**: Use extractErrorMessage for complete error display in mutations
|
||||
- **Sponsors**: Add AICodeMirror and reorder sponsor list
|
||||
|
||||
---
|
||||
|
||||
## [3.10.2] - 2026-01-24
|
||||
|
||||
### Patch Release
|
||||
|
||||
@@ -33,8 +33,9 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
||||
</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>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
|
||||
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -42,6 +43,11 @@ 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>
|
||||
</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>
|
||||
|
||||
</table>
|
||||
|
||||
## Screenshots
|
||||
|
||||
+8
-2
@@ -33,8 +33,9 @@
|
||||
</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>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
|
||||
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -42,6 +43,11 @@
|
||||
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
+10
-3
@@ -31,10 +31,11 @@
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
|
||||
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</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>感谢 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>
|
||||
@@ -42,6 +43,12 @@
|
||||
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
|
||||
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 界面预览
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 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);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.10.2",
|
||||
"version": "3.10.3",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.10.2"
|
||||
version = "3.10.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.10.2"
|
||||
version = "3.10.3"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
+26
-1
@@ -1,3 +1,28 @@
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -2,21 +2,14 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
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 serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
|
||||
@@ -109,3 +109,17 @@ pub async fn open_file_dialog<R: tauri::Runtime>(
|
||||
|
||||
Ok(result.map(|p| p.to_string()))
|
||||
}
|
||||
|
||||
/// 打开 ZIP 文件选择对话框
|
||||
#[tauri::command]
|
||||
pub async fn open_zip_file_dialog<R: tauri::Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let dialog = app.dialog();
|
||||
let result = dialog
|
||||
.file()
|
||||
.add_filter("ZIP", &["zip"])
|
||||
.blocking_pick_file();
|
||||
|
||||
Ok(result.map(|p| p.to_string()))
|
||||
}
|
||||
|
||||
+234
-37
@@ -89,7 +89,7 @@ pub struct ToolVersion {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||
let tools = vec!["claude", "codex", "gemini"];
|
||||
let tools = vec!["claude", "codex", "gemini", "opencode"];
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 使用全局 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,
|
||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -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> =
|
||||
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 只能是预定义的值
|
||||
debug_assert!(
|
||||
["claude", "codex", "gemini"].contains(&tool),
|
||||
["claude", "codex", "gemini", "opencode"].contains(&tool),
|
||||
"unexpected tool name: {tool}"
|
||||
);
|
||||
|
||||
@@ -295,10 +319,10 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
|
||||
// 常见的 npm 全局安装路径
|
||||
// 常见的安装路径(原生安装优先)
|
||||
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
||||
home.join(".local/bin"), // Native install (official recommended)
|
||||
home.join(".npm-global/bin"),
|
||||
home.join(".local/bin"),
|
||||
home.join("n/bin"), // n version manager
|
||||
];
|
||||
|
||||
@@ -348,6 +372,14 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 Go 路径支持 (opencode 使用 go install 安装)
|
||||
if tool == "opencode" {
|
||||
search_paths.push(home.join("go/bin")); // go install 默认路径
|
||||
if let Ok(gopath) = std::env::var("GOPATH") {
|
||||
search_paths.push(std::path::PathBuf::from(gopath).join("bin"));
|
||||
}
|
||||
}
|
||||
|
||||
// 在每个路径中查找工具
|
||||
for path in &search_paths {
|
||||
let tool_path = if cfg!(target_os = "windows") {
|
||||
@@ -405,6 +437,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
"claude" => crate::settings::get_claude_override_dir(),
|
||||
"codex" => crate::settings::get_codex_override_dir(),
|
||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||
"opencode" => crate::settings::get_opencode_override_dir(),
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
@@ -581,18 +614,19 @@ fn write_claude_config(
|
||||
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||
}
|
||||
|
||||
/// macOS: 使用 Terminal.app 启动
|
||||
/// macOS: 根据用户首选终端启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("terminal");
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
|
||||
let config_path = config_file.to_string_lossy();
|
||||
|
||||
// Write the shell script to a temp file (no escaping needed!)
|
||||
// Write the shell script to a temp file
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||
@@ -611,7 +645,34 @@ exec bash --norc --noprofile
|
||||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||
.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),
|
||||
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||||
};
|
||||
|
||||
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
|
||||
if result.is_err() && terminal != "terminal" {
|
||||
log::warn!(
|
||||
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
|
||||
terminal,
|
||||
result.as_ref().err()
|
||||
);
|
||||
return launch_macos_terminal_app(&script_file);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// macOS: Terminal.app
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let applescript = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
@@ -627,12 +688,9 @@ end tell"#,
|
||||
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Clean up on failure
|
||||
let _ = std::fs::remove_file(&script_file);
|
||||
let _ = std::fs::remove_file(config_file);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"AppleScript 执行失败 (exit code: {:?}): {}",
|
||||
"Terminal.app 执行失败 (exit code: {:?}): {}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
@@ -641,13 +699,86 @@ end tell"#,
|
||||
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!("启动 {} 失败: {e}", app_name))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"{} 启动失败 (exit code: {:?}): {}",
|
||||
app_name,
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Linux: 根据用户首选终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
let terminals = [
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
|
||||
// Default terminal list with their arguments
|
||||
let default_terminals = [
|
||||
("gnome-terminal", vec!["--"]),
|
||||
("konsole", vec!["-e"]),
|
||||
("xfce4-terminal", vec!["-e"]),
|
||||
@@ -655,9 +786,10 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
("lxterminal", vec!["-e"]),
|
||||
("alacritty", vec!["-e"]),
|
||||
("kitty", vec!["-e"]),
|
||||
("ghostty", vec!["-e"]),
|
||||
];
|
||||
|
||||
// Create temp script file (same approach as macOS)
|
||||
// Create temp script file
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
let config_path = config_file.to_string_lossy();
|
||||
@@ -679,25 +811,48 @@ exec bash --norc --noprofile
|
||||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||
|
||||
// Build terminal list: preferred terminal first (if specified), then defaults
|
||||
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
|
||||
// Find the preferred terminal's args from default list
|
||||
let pref_args = default_terminals
|
||||
.iter()
|
||||
.find(|(name, _)| *name == pref.as_str())
|
||||
.map(|(_, args)| args.iter().map(|s| *s).collect::<Vec<&str>>())
|
||||
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
|
||||
|
||||
let mut list = vec![(pref.as_str(), pref_args)];
|
||||
// Add remaining terminals as fallbacks
|
||||
for (name, args) in &default_terminals {
|
||||
if *name != pref.as_str() {
|
||||
list.push((*name, args.iter().map(|s| *s).collect()));
|
||||
}
|
||||
}
|
||||
list
|
||||
} else {
|
||||
default_terminals
|
||||
.iter()
|
||||
.map(|(name, args)| (*name, args.iter().map(|s| *s).collect()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut last_error = String::from("未找到可用的终端");
|
||||
|
||||
for (terminal, args) in terminals {
|
||||
// Check if terminal exists
|
||||
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||
for (terminal, args) in terminals_to_try {
|
||||
// Check if terminal exists in common paths
|
||||
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||||
{
|
||||
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists()
|
||||
|| which_command(terminal);
|
||||
|
||||
if terminal_exists {
|
||||
let result = Command::new(terminal)
|
||||
.args(&args)
|
||||
.arg("bash")
|
||||
.arg(script_file.to_string_lossy().as_ref())
|
||||
.output();
|
||||
.spawn();
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => return Ok(()),
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
last_error = format!("启动 {} 失败: {}", terminal, stderr);
|
||||
}
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
last_error = format!("执行 {} 失败: {}", terminal, e);
|
||||
}
|
||||
@@ -711,13 +866,25 @@ exec bash --norc --noprofile
|
||||
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")]
|
||||
fn launch_windows_terminal(
|
||||
temp_dir: &std::path::Path,
|
||||
config_file: &std::path::Path,
|
||||
) -> 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 config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||
@@ -733,23 +900,53 @@ del \"%~f0\" >nul 2>&1
|
||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||
);
|
||||
|
||||
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
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")
|
||||
.args(["/C", "start", "cmd", "/K", &bat_file.to_string_lossy()])
|
||||
.args(&full_args)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| format!("执行 cmd 失败: {e}"))?;
|
||||
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Clean up on failure
|
||||
let _ = std::fs::remove_file(&bat_file);
|
||||
let _ = std::fs::remove_file(config_file);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"启动 Windows 终端失败 (exit code: {:?}): {}",
|
||||
"{} 启动失败 (exit code: {:?}): {}",
|
||||
terminal_name,
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! 提供前端调用的 API 接口
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::types::*;
|
||||
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
||||
use crate::store::AppState;
|
||||
@@ -119,6 +120,120 @@ pub async fn update_proxy_config_for_app(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn get_default_cost_multiplier_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let db = &state.db;
|
||||
db.get_default_cost_multiplier(app_type).await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
pub async fn get_default_cost_multiplier_test_hook(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
get_default_cost_multiplier_internal(state, app_type).await
|
||||
}
|
||||
|
||||
/// 获取默认成本倍率
|
||||
#[tauri::command]
|
||||
pub async fn get_default_cost_multiplier(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<String, String> {
|
||||
get_default_cost_multiplier_internal(&state, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn set_default_cost_multiplier_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let db = &state.db;
|
||||
db.set_default_cost_multiplier(app_type, value).await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
pub async fn set_default_cost_multiplier_test_hook(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
set_default_cost_multiplier_internal(state, app_type, value).await
|
||||
}
|
||||
|
||||
/// 设置默认成本倍率
|
||||
#[tauri::command]
|
||||
pub async fn set_default_cost_multiplier(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
set_default_cost_multiplier_internal(&state, &app_type, &value)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn get_pricing_model_source_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let db = &state.db;
|
||||
db.get_pricing_model_source(app_type).await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
pub async fn get_pricing_model_source_test_hook(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
get_pricing_model_source_internal(state, app_type).await
|
||||
}
|
||||
|
||||
/// 获取计费模式来源
|
||||
#[tauri::command]
|
||||
pub async fn get_pricing_model_source(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<String, String> {
|
||||
get_pricing_model_source_internal(&state, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn set_pricing_model_source_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let db = &state.db;
|
||||
db.set_pricing_model_source(app_type, value).await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
pub async fn set_pricing_model_source_test_hook(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
set_pricing_model_source_internal(state, app_type, value).await
|
||||
}
|
||||
|
||||
/// 设置计费模式来源
|
||||
#[tauri::command]
|
||||
pub async fn set_pricing_model_source(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
set_pricing_model_source_internal(&state, &app_type, &value)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 检查代理服务器是否正在运行
|
||||
#[tauri::command]
|
||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||
|
||||
@@ -249,3 +249,16 @@ pub fn remove_skill_repo(
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 从 ZIP 文件安装 Skills
|
||||
#[tauri::command]
|
||||
pub fn install_skills_from_zip(
|
||||
file_path: String,
|
||||
current_app: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<InstalledSkill>, String> {
|
||||
let app_type = parse_app_type(¤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())
|
||||
}
|
||||
|
||||
+12
-5
@@ -6,7 +6,17 @@ use std::path::{Path, PathBuf};
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
///
|
||||
/// On Windows, respects the `HOME` environment variable (if set) to support
|
||||
/// test isolation. Falls back to `dirs::home_dir()` otherwise.
|
||||
pub fn get_home_dir() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let trimmed = home.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
@@ -71,10 +81,7 @@ pub fn get_app_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".cc-switch")
|
||||
get_home_dir().join(".cc-switch")
|
||||
}
|
||||
|
||||
/// 获取应用配置文件路径
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::types::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use super::super::{lock_conn, Database};
|
||||
|
||||
@@ -75,6 +76,117 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取默认成本倍率
|
||||
pub async fn get_default_cost_multiplier(&self, app_type: &str) -> Result<String, AppError> {
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT default_cost_multiplier FROM proxy_config WHERE app_type = ?1",
|
||||
[app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok("1".to_string())
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置默认成本倍率
|
||||
pub async fn set_default_cost_multiplier(
|
||||
&self,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"error.multiplierEmpty",
|
||||
"倍率不能为空",
|
||||
"Multiplier cannot be empty",
|
||||
));
|
||||
}
|
||||
trimmed.parse::<Decimal>().map_err(|e| {
|
||||
AppError::localized(
|
||||
"error.invalidMultiplier",
|
||||
format!("无效倍率: {value} - {e}"),
|
||||
format!("Invalid multiplier: {value} - {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 确保行存在
|
||||
self.ensure_proxy_config_row_exists(app_type)?;
|
||||
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET
|
||||
default_cost_multiplier = ?2,
|
||||
updated_at = datetime('now')
|
||||
WHERE app_type = ?1",
|
||||
rusqlite::params![app_type, trimmed],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取计费模式来源
|
||||
pub async fn get_pricing_model_source(&self, app_type: &str) -> Result<String, AppError> {
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT pricing_model_source FROM proxy_config WHERE app_type = ?1",
|
||||
[app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok("response".to_string())
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置计费模式来源
|
||||
pub async fn set_pricing_model_source(
|
||||
&self,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let trimmed = value.trim();
|
||||
if !matches!(trimmed, "response" | "request") {
|
||||
return Err(AppError::localized(
|
||||
"error.invalidPricingMode",
|
||||
format!("无效计费模式: {value}"),
|
||||
format!("Invalid pricing mode: {value}"),
|
||||
));
|
||||
}
|
||||
|
||||
// 确保行存在
|
||||
self.ensure_proxy_config_row_exists(app_type)?;
|
||||
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET
|
||||
pricing_model_source = ?2,
|
||||
updated_at = datetime('now')
|
||||
WHERE app_type = ?1",
|
||||
rusqlite::params![app_type, trimmed],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取应用级代理配置
|
||||
pub async fn get_proxy_config_for_app(
|
||||
&self,
|
||||
@@ -177,17 +289,90 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确保指定 app_type 的 proxy_config 行存在(同步版本,用于 set_* 函数)
|
||||
///
|
||||
/// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||
fn ensure_proxy_config_row_exists(&self, app_type: &str) -> Result<(), AppError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| AppError::Lock(e.to_string()))?;
|
||||
|
||||
// 根据 app_type 使用不同的默认值(与 schema.rs seed 保持一致)
|
||||
let (retries, fb_timeout, idle_timeout, cb_fail, cb_succ, cb_timeout, cb_rate, cb_min) =
|
||||
match app_type {
|
||||
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
|
||||
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES (?1, ?2, ?3, ?4, 600, ?5, ?6, ?7, ?8, ?9)",
|
||||
rusqlite::params![
|
||||
app_type,
|
||||
retries,
|
||||
fb_timeout,
|
||||
idle_timeout,
|
||||
cb_fail,
|
||||
cb_succ,
|
||||
cb_timeout,
|
||||
cb_rate,
|
||||
cb_min
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化 proxy_config 表的三行数据
|
||||
///
|
||||
/// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
for app_type in &["claude", "codex", "gemini"] {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
|
||||
[app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||
// claude: 更激进的重试和超时配置
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// codex: 默认配置
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// gemini: 稍高的重试次数
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -662,3 +847,70 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_cost_multiplier_round_trip() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
let default = db.get_default_cost_multiplier("claude").await?;
|
||||
assert_eq!(default, "1");
|
||||
|
||||
db.set_default_cost_multiplier("claude", "1.5").await?;
|
||||
let updated = db.get_default_cost_multiplier("claude").await?;
|
||||
assert_eq!(updated, "1.5");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_cost_multiplier_validation() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
let err = db
|
||||
.set_default_cost_multiplier("claude", "not-a-number")
|
||||
.await
|
||||
.unwrap_err();
|
||||
// AppError::localized returns AppError::Localized variant
|
||||
assert!(matches!(
|
||||
err,
|
||||
AppError::Localized {
|
||||
key: "error.invalidMultiplier",
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pricing_model_source_round_trip_and_validation() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
let default = db.get_pricing_model_source("claude").await?;
|
||||
assert_eq!(default, "response");
|
||||
|
||||
db.set_pricing_model_source("claude", "request").await?;
|
||||
let updated = db.get_pricing_model_source("claude").await?;
|
||||
assert_eq!(updated, "request");
|
||||
|
||||
let err = db
|
||||
.set_pricing_model_source("claude", "invalid")
|
||||
.await
|
||||
.unwrap_err();
|
||||
// AppError::localized returns AppError::Localized variant
|
||||
assert!(matches!(
|
||||
err,
|
||||
AppError::Localized {
|
||||
key: "error.invalidPricingMode",
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 4;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
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_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -170,6 +172,7 @@ impl Database {
|
||||
// 10. Proxy Request Logs 表
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||
request_model TEXT,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
@@ -352,6 +355,11 @@ impl Database {
|
||||
Self::migrate_v3_to_v4(conn)?;
|
||||
Self::set_user_version(conn, 4)?;
|
||||
}
|
||||
4 => {
|
||||
log::info!("迁移数据库从 v4 到 v5(计费模式支持)");
|
||||
Self::migrate_v4_to_v5(conn)?;
|
||||
Self::set_user_version(conn, 5)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -521,6 +529,7 @@ impl Database {
|
||||
// proxy_request_logs 表
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||
request_model TEXT,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
@@ -677,6 +686,8 @@ impl Database {
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", [])?;
|
||||
|
||||
@@ -879,6 +890,30 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v4 -> v5 迁移:新增计费模式配置与请求模型字段
|
||||
fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {
|
||||
if Self::table_exists(conn, "proxy_config")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"default_cost_multiplier",
|
||||
"TEXT NOT NULL DEFAULT '1'",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"pricing_model_source",
|
||||
"TEXT NOT NULL DEFAULT 'response'",
|
||||
)?;
|
||||
}
|
||||
if Self::table_exists(conn, "proxy_request_logs")? {
|
||||
Self::add_column_if_missing(conn, "proxy_request_logs", "request_model", "TEXT")?;
|
||||
}
|
||||
|
||||
log::info!("v4 -> v5 迁移完成:已添加计费模式与请求模型字段");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
|
||||
@@ -151,7 +151,7 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
@@ -169,7 +169,7 @@ fn migration_sets_user_version_when_missing() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_rejects_future_version() {
|
||||
fn schema_migration_rejects_future_version() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
||||
@@ -183,7 +183,7 @@ fn migration_rejects_future_version() {
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
// 创建旧版 providers 表,缺少新增列
|
||||
@@ -224,7 +224,7 @@ fn migration_adds_missing_columns_for_providers() {
|
||||
}
|
||||
|
||||
#[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");
|
||||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||||
.expect("seed old schema");
|
||||
@@ -268,7 +268,67 @@ fn migration_aligns_column_defaults_and_types() {
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
// 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type)
|
||||
@@ -433,7 +493,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_does_not_write_to_disk() {
|
||||
fn schema_dry_run_does_not_write_to_disk() {
|
||||
// Create minimal valid config for migration
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
@@ -507,7 +567,7 @@ fn dry_run_validates_schema_compatibility() {
|
||||
}
|
||||
|
||||
#[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 conn = db.conn.lock().expect("lock conn");
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
use crate::config::write_text_file;
|
||||
use crate::config::{get_home_dir, write_text_file};
|
||||
use crate::error::AppError;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||
pub fn get_gemini_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -7,14 +6,6 @@ use crate::config::atomic_write;
|
||||
use crate::error::AppError;
|
||||
use crate::gemini_config::get_gemini_settings_path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpStatus {
|
||||
pub user_config_path: String,
|
||||
pub user_config_exists: bool,
|
||||
pub server_count: usize,
|
||||
}
|
||||
|
||||
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json)
|
||||
fn user_config_path() -> PathBuf {
|
||||
get_gemini_settings_path()
|
||||
|
||||
@@ -745,6 +745,24 @@ pub fn run() {
|
||||
restore_proxy_state_on_startup(&state).await;
|
||||
});
|
||||
|
||||
// 静默启动:根据设置决定是否显示主窗口
|
||||
let settings = crate::settings::get_settings();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if settings.silent_startup {
|
||||
// 静默启动模式:保持窗口隐藏
|
||||
let _ = window.hide();
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = window.set_skip_taskbar(true);
|
||||
#[cfg(target_os = "macos")]
|
||||
tray::apply_tray_policy(app.handle(), false);
|
||||
log::info!("静默启动模式:主窗口已隐藏");
|
||||
} else {
|
||||
// 正常启动模式:显示窗口
|
||||
let _ = window.show();
|
||||
log::info!("正常启动模式:主窗口已显示");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -832,6 +850,7 @@ pub fn run() {
|
||||
commands::import_config_from_file,
|
||||
commands::save_file_dialog,
|
||||
commands::open_file_dialog,
|
||||
commands::open_zip_file_dialog,
|
||||
commands::sync_current_providers_live,
|
||||
// Deep link import
|
||||
commands::parse_deeplink,
|
||||
@@ -861,6 +880,7 @@ pub fn run() {
|
||||
commands::get_skill_repos,
|
||||
commands::add_skill_repo,
|
||||
commands::remove_skill_repo,
|
||||
commands::install_skills_from_zip,
|
||||
// Auto launch
|
||||
commands::set_auto_launch,
|
||||
commands::get_auto_launch_status,
|
||||
@@ -877,6 +897,10 @@ pub fn run() {
|
||||
commands::update_global_proxy_config,
|
||||
commands::get_proxy_config_for_app,
|
||||
commands::update_proxy_config_for_app,
|
||||
commands::get_default_cost_multiplier,
|
||||
commands::set_default_cost_multiplier,
|
||||
commands::get_pricing_model_source,
|
||||
commands::set_pricing_model_source,
|
||||
commands::is_proxy_running,
|
||||
commands::is_live_takeover_active,
|
||||
commands::switch_proxy_provider,
|
||||
|
||||
+326
-4
@@ -215,6 +215,9 @@ pub struct ProviderMeta {
|
||||
/// 成本倍数(用于计算实际成本)
|
||||
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
|
||||
pub cost_multiplier: Option<String>,
|
||||
/// 计费模式来源(response/request)
|
||||
#[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
|
||||
pub pricing_model_source: Option<String>,
|
||||
/// 每日消费限额(USD)
|
||||
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
|
||||
pub limit_daily_usd: Option<String>,
|
||||
@@ -227,6 +230,11 @@ pub struct ProviderMeta {
|
||||
/// 供应商单独的代理配置
|
||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_config: Option<ProviderProxyConfig>,
|
||||
/// Claude API 格式(仅 Claude 供应商使用)
|
||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
@@ -438,11 +446,18 @@ impl UniversalProvider {
|
||||
.and_then(|m| m.reasoning_effort.clone())
|
||||
.unwrap_or_else(|| "high".to_string());
|
||||
|
||||
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API)
|
||||
let codex_base_url = if self.base_url.ends_with("/v1") {
|
||||
self.base_url.clone()
|
||||
// Codex/OpenAI 的 base_url 既可能是纯 origin(需要补 /v1),也可能包含自定义前缀(不应强行补版本)
|
||||
let base_trimmed = self.base_url.trim_end_matches('/');
|
||||
let origin_only = match base_trimmed.split_once("://") {
|
||||
Some((_scheme, rest)) => !rest.contains('/'),
|
||||
None => !base_trimmed.contains('/'),
|
||||
};
|
||||
let codex_base_url = if base_trimmed.ends_with("/v1") {
|
||||
base_trimmed.to_string()
|
||||
} else if origin_only {
|
||||
format!("{base_trimmed}/v1")
|
||||
} else {
|
||||
format!("{}/v1", self.base_url.trim_end_matches('/'))
|
||||
base_trimmed.to_string()
|
||||
};
|
||||
|
||||
// 生成 Codex 的 config.toml 内容
|
||||
@@ -614,3 +629,310 @@ pub struct OpenCodeModelLimit {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
||||
ProviderManager, ProviderMeta, UniversalProvider,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn provider_meta_serializes_pricing_model_source() {
|
||||
let mut meta = ProviderMeta::default();
|
||||
meta.pricing_model_source = Some("response".to_string());
|
||||
|
||||
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||
|
||||
assert_eq!(
|
||||
value
|
||||
.get("pricingModelSource")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("response")
|
||||
);
|
||||
assert!(value.get("pricing_model_source").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_meta_omits_pricing_model_source_when_none() {
|
||||
let meta = ProviderMeta::default();
|
||||
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||
|
||||
assert!(value.get("pricingModelSource").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_with_id_populates_defaults() {
|
||||
let settings_config = json!({
|
||||
"env": { "API_KEY": "test" }
|
||||
});
|
||||
let provider = Provider::with_id(
|
||||
"provider-1".to_string(),
|
||||
"Provider".to_string(),
|
||||
settings_config.clone(),
|
||||
Some("https://example.com".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(provider.id, "provider-1");
|
||||
assert_eq!(provider.name, "Provider");
|
||||
assert_eq!(provider.settings_config, settings_config);
|
||||
assert_eq!(provider.website_url.as_deref(), Some("https://example.com"));
|
||||
assert!(provider.category.is_none());
|
||||
assert!(provider.created_at.is_none());
|
||||
assert!(provider.sort_index.is_none());
|
||||
assert!(provider.notes.is_none());
|
||||
assert!(provider.meta.is_none());
|
||||
assert!(provider.icon.is_none());
|
||||
assert!(provider.icon_color.is_none());
|
||||
assert!(!provider.in_failover_queue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_manager_get_all_providers_returns_map() {
|
||||
let mut manager = ProviderManager::default();
|
||||
let provider = Provider::with_id(
|
||||
"provider-1".to_string(),
|
||||
"Provider".to_string(),
|
||||
json!({ "env": {} }),
|
||||
None,
|
||||
);
|
||||
manager.providers.insert("provider-1".to_string(), provider);
|
||||
|
||||
assert_eq!(manager.get_all_providers().len(), 1);
|
||||
assert!(manager.get_all_providers().contains_key("provider-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_claude_provider_uses_models() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.claude = true;
|
||||
universal.models.claude = Some(ClaudeModelConfig {
|
||||
model: Some("claude-main".to_string()),
|
||||
haiku_model: Some("claude-haiku".to_string()),
|
||||
sonnet_model: Some("claude-sonnet".to_string()),
|
||||
opus_model: Some("claude-opus".to_string()),
|
||||
});
|
||||
|
||||
let provider = universal.to_claude_provider().expect("claude provider");
|
||||
|
||||
assert_eq!(provider.id, "universal-claude-u1");
|
||||
assert_eq!(provider.name, "Universal");
|
||||
assert_eq!(provider.category.as_deref(), Some("aggregator"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("claude-main")
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("claude-haiku")
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("claude-sonnet")
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("claude-opus")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_claude_provider_disabled_returns_none() {
|
||||
let universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
|
||||
assert!(universal.to_claude_provider().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_codex_provider_appends_v1() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.codex = true;
|
||||
universal.models.codex = Some(CodexModelConfig {
|
||||
model: Some("gpt-4o-mini".to_string()),
|
||||
reasoning_effort: Some("low".to_string()),
|
||||
});
|
||||
|
||||
let provider = universal.to_codex_provider().expect("codex provider");
|
||||
let config = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|item| item.as_str())
|
||||
.expect("config toml");
|
||||
|
||||
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/auth/OPENAI_API_KEY")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("api-key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_codex_provider_keeps_v1_suffix() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com/v1".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.codex = true;
|
||||
|
||||
let provider = universal.to_codex_provider().expect("codex provider");
|
||||
let config = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|item| item.as_str())
|
||||
.expect("config toml");
|
||||
|
||||
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_codex_provider_disabled_returns_none() {
|
||||
let universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
|
||||
assert!(universal.to_codex_provider().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_gemini_provider_defaults_model() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.gemini = true;
|
||||
|
||||
let provider = universal.to_gemini_provider().expect("gemini provider");
|
||||
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/GEMINI_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("gemini-2.5-pro")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_gemini_provider_uses_model() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.gemini = true;
|
||||
universal.models.gemini = Some(GeminiModelConfig {
|
||||
model: Some("gemini-custom".to_string()),
|
||||
});
|
||||
|
||||
let provider = universal.to_gemini_provider().expect("gemini provider");
|
||||
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/GEMINI_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("gemini-custom")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_provider_config_defaults() {
|
||||
let config = OpenCodeProviderConfig::default();
|
||||
assert_eq!(config.npm, "@ai-sdk/openai-compatible");
|
||||
assert!(config.name.is_none());
|
||||
assert!(config.models.is_empty());
|
||||
assert!(config.options.base_url.is_none());
|
||||
assert!(config.options.api_key.is_none());
|
||||
assert!(config.options.headers.is_none());
|
||||
assert!(config.options.extra.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_codex_provider_origin_base_url_adds_v1() {
|
||||
let mut p = UniversalProvider::new(
|
||||
"id".to_string(),
|
||||
"Test".to_string(),
|
||||
"custom".to_string(),
|
||||
"https://api.openai.com".to_string(),
|
||||
"sk-test".to_string(),
|
||||
);
|
||||
p.apps.codex = true;
|
||||
|
||||
let provider = p.to_codex_provider().expect("should build codex provider");
|
||||
let toml = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config should be a toml string");
|
||||
|
||||
assert!(toml.contains("base_url = \"https://api.openai.com/v1\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_codex_provider_custom_prefix_does_not_force_v1() {
|
||||
let mut p = UniversalProvider::new(
|
||||
"id".to_string(),
|
||||
"Test".to_string(),
|
||||
"custom".to_string(),
|
||||
"https://example.com/openai".to_string(),
|
||||
"sk-test".to_string(),
|
||||
);
|
||||
p.apps.codex = true;
|
||||
|
||||
let provider = p.to_codex_provider().expect("should build codex provider");
|
||||
let toml = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config should be a toml string");
|
||||
|
||||
assert!(toml.contains("base_url = \"https://example.com/openai\""));
|
||||
assert!(!toml.contains("https://example.com/openai/v1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,7 +666,11 @@ impl RequestForwarder {
|
||||
|
||||
// 输出请求信息日志
|
||||
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) {
|
||||
log::debug!(
|
||||
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
||||
|
||||
@@ -22,9 +22,7 @@ use super::{
|
||||
};
|
||||
use crate::app_config::AppType;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
|
||||
// ============================================================================
|
||||
// 健康检查和状态查询(简单端点)
|
||||
@@ -145,6 +143,7 @@ async fn handle_claude_transform(
|
||||
&provider_id,
|
||||
"claude",
|
||||
&model,
|
||||
&model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -215,6 +214,7 @@ async fn handle_claude_transform(
|
||||
.unwrap_or("unknown");
|
||||
let latency_ms = ctx.latency_ms();
|
||||
|
||||
let request_model = ctx.request_model.clone();
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
@@ -225,6 +225,7 @@ async fn handle_claude_transform(
|
||||
&provider_id,
|
||||
"claude",
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
@@ -283,7 +284,7 @@ pub async fn handle_chat_completions(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
@@ -324,7 +325,7 @@ pub async fn handle_responses(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/v1/responses",
|
||||
"/responses",
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
@@ -441,6 +442,7 @@ async fn log_usage(
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
usage: TokenUsage,
|
||||
latency_ms: u64,
|
||||
first_token_ms: Option<u64>,
|
||||
@@ -451,25 +453,12 @@ async fn log_usage(
|
||||
|
||||
let logger = UsageLogger::new(&state.db);
|
||||
|
||||
// 获取 provider 的 cost_multiplier
|
||||
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
||||
Ok(Some(p)) => {
|
||||
if let Some(meta) = p.meta {
|
||||
if let Some(cm) = meta.cost_multiplier {
|
||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
||||
);
|
||||
Decimal::from(1)
|
||||
})
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
}
|
||||
_ => Decimal::from(1),
|
||||
let (multiplier, pricing_model_source) =
|
||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||
let pricing_model = if pricing_model_source == "request" {
|
||||
request_model
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -479,6 +468,8 @@ async fn log_usage(
|
||||
provider_id.to_string(),
|
||||
app_type.to_string(),
|
||||
model.to_string(),
|
||||
request_model.to_string(),
|
||||
pricing_model.to_string(),
|
||||
usage,
|
||||
multiplier,
|
||||
latency_ms,
|
||||
|
||||
@@ -17,6 +17,33 @@ static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
|
||||
/// 当前代理 URL(用于日志和状态查询)
|
||||
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
|
||||
|
||||
/// CC Switch 代理服务器当前监听的端口
|
||||
static CC_SWITCH_PROXY_PORT: OnceCell<RwLock<u16>> = OnceCell::new();
|
||||
|
||||
/// 设置 CC Switch 代理服务器的监听端口
|
||||
///
|
||||
/// 应在代理服务器启动时调用,以便系统代理检测能正确识别自己的端口
|
||||
pub fn set_proxy_port(port: u16) {
|
||||
if let Some(lock) = CC_SWITCH_PROXY_PORT.get() {
|
||||
if let Ok(mut current_port) = lock.write() {
|
||||
*current_port = port;
|
||||
log::debug!("[GlobalProxy] Updated CC Switch proxy port to {port}");
|
||||
}
|
||||
} else {
|
||||
let _ = CC_SWITCH_PROXY_PORT.set(RwLock::new(port));
|
||||
log::debug!("[GlobalProxy] Initialized CC Switch proxy port to {port}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 CC Switch 代理服务器的监听端口
|
||||
fn get_proxy_port() -> u16 {
|
||||
CC_SWITCH_PROXY_PORT
|
||||
.get()
|
||||
.and_then(|lock| lock.read().ok())
|
||||
.map(|port| *port)
|
||||
.unwrap_or(15721) // 默认端口作为回退
|
||||
}
|
||||
|
||||
/// 初始化全局 HTTP 客户端
|
||||
///
|
||||
/// 应在应用启动时调用一次。
|
||||
@@ -258,9 +285,17 @@ fn proxy_points_to_loopback(value: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// 检查是否指向 CC Switch 自己的代理端口
|
||||
// 只有指向自己的代理才需要跳过,避免递归
|
||||
fn is_cc_switch_proxy_port(port: Option<u16>) -> bool {
|
||||
let cc_switch_port = get_proxy_port();
|
||||
port == Some(cc_switch_port)
|
||||
}
|
||||
|
||||
if let Ok(parsed) = url::Url::parse(value) {
|
||||
if let Some(host) = parsed.host_str() {
|
||||
return host_is_loopback(host);
|
||||
// 只有当主机是 loopback 且端口是 CC Switch 的端口时才返回 true
|
||||
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -268,7 +303,7 @@ fn proxy_points_to_loopback(value: &str) -> bool {
|
||||
let with_scheme = format!("http://{value}");
|
||||
if let Ok(parsed) = url::Url::parse(&with_scheme) {
|
||||
if let Some(host) = parsed.host_str() {
|
||||
return host_is_loopback(host);
|
||||
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,16 +483,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_proxy_points_to_loopback() {
|
||||
assert!(proxy_points_to_loopback("http://127.0.0.1:7890"));
|
||||
assert!(proxy_points_to_loopback("socks5://localhost:1080"));
|
||||
assert!(proxy_points_to_loopback("127.0.0.1:7890"));
|
||||
// 设置 CC Switch 代理端口为 15721(默认值)
|
||||
set_proxy_port(15721);
|
||||
|
||||
// 只有指向 CC Switch 自己端口的 loopback 地址才返回 true
|
||||
assert!(proxy_points_to_loopback("http://127.0.0.1:15721"));
|
||||
assert!(proxy_points_to_loopback("socks5://localhost:15721"));
|
||||
assert!(proxy_points_to_loopback("127.0.0.1:15721"));
|
||||
|
||||
// 其他 loopback 端口不应该被跳过(允许使用其他本地代理工具)
|
||||
assert!(!proxy_points_to_loopback("http://127.0.0.1:7890"));
|
||||
assert!(!proxy_points_to_loopback("socks5://localhost:1080"));
|
||||
|
||||
// 非 loopback 地址不应该被跳过
|
||||
assert!(!proxy_points_to_loopback("http://192.168.1.10:7890"));
|
||||
assert!(!proxy_points_to_loopback("http://192.168.1.10:15721"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_proxy_points_to_loopback() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
|
||||
// 设置 CC Switch 代理端口
|
||||
set_proxy_port(15721);
|
||||
|
||||
let keys = [
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
@@ -471,9 +520,15 @@ mod tests {
|
||||
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());
|
||||
|
||||
// 指向其他端口的本地代理不应该被跳过
|
||||
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
|
||||
assert!(!system_proxy_points_to_loopback());
|
||||
|
||||
// 非 loopback 地址不应该被跳过
|
||||
std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890");
|
||||
assert!(!system_proxy_points_to_loopback());
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! 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)
|
||||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
|
||||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
@@ -48,22 +52,52 @@ impl ClaudeAdapter {
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测 OpenRouter 是否启用兼容模式
|
||||
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
|
||||
if !self.is_openrouter(provider) {
|
||||
return false;
|
||||
/// 获取 API 格式
|
||||
///
|
||||
/// 从 provider.meta.api_format 读取格式设置:
|
||||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
return if api_format == "openai_chat" {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Backward compatibility: legacy settings_config.api_format
|
||||
if let Some(api_format) = provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return if api_format == "openai_chat" {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
match raw {
|
||||
Some(serde_json::Value::Bool(enabled)) => *enabled,
|
||||
let enabled = match raw {
|
||||
Some(serde_json::Value::Bool(v)) => *v,
|
||||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||
Some(serde_json::Value::String(value)) => {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
// OpenRouter now supports Claude Code compatible API, default to passthrough
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if enabled {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,15 +252,23 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
let base = format!(
|
||||
let mut base = format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_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)验证请求来源的关键参数
|
||||
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")
|
||||
} else {
|
||||
base
|
||||
@@ -253,21 +295,19 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||
// NOTE:
|
||||
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
|
||||
// Anthropic ↔ OpenAI 的格式转换。
|
||||
//
|
||||
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
||||
self.is_openrouter_compat_enabled(_provider)
|
||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
|
||||
self.get_api_format(provider) == "openai_chat"
|
||||
}
|
||||
|
||||
fn transform_request(
|
||||
&self,
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
_provider: &Provider,
|
||||
) -> 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> {
|
||||
@@ -278,6 +318,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::ProviderMeta;
|
||||
use serde_json::json;
|
||||
|
||||
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]
|
||||
fn test_extract_base_url_from_env() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
@@ -459,6 +517,7 @@ mod tests {
|
||||
fn test_needs_transform() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
// Default: no transform (anthropic format) - no meta
|
||||
let anthropic_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
@@ -466,29 +525,96 @@ mod tests {
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||
|
||||
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
|
||||
let openrouter_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
}
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&openrouter_provider));
|
||||
// Explicit anthropic format in meta: no transform
|
||||
let explicit_anthropic = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("anthropic".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(!adapter.needs_transform(&explicit_anthropic));
|
||||
|
||||
// OpenRouter provider with explicit compat mode enabled should transform
|
||||
let openrouter_enabled = create_provider(json!({
|
||||
// Legacy settings_config.api_format: openai_chat should enable transform
|
||||
let legacy_settings_api_format = create_provider(json!({
|
||||
"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
|
||||
}));
|
||||
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": {
|
||||
"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 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
|
||||
if url.contains("/v1/v1") {
|
||||
// 检查 base_url 是否已经包含 /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");
|
||||
}
|
||||
|
||||
@@ -223,6 +246,20 @@ mod tests {
|
||||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_origin_adds_v1() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let url = adapter.build_url("https://api.openai.com", "/responses");
|
||||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_custom_prefix_no_v1() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let url = adapter.build_url("https://example.com/openai", "/responses");
|
||||
assert_eq!(url, "https://example.com/openai/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_dedup_v1() {
|
||||
let adapter = CodexAdapter::new();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! 用于 Anthropic Messages API 的请求/响应格式转换
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -3,77 +3,16 @@
|
||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||
//! 参考: anthropic-proxy-rs
|
||||
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
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 请求
|
||||
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!({});
|
||||
|
||||
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
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!(mapped_model);
|
||||
result["model"] = json!(model);
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
@@ -381,45 +320,16 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
mod tests {
|
||||
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]
|
||||
fn test_anthropic_to_openai_simple() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["model"], "claude-3-opus");
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
assert_eq!(result["messages"][0]["content"], "Hello");
|
||||
@@ -427,7 +337,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_system() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
@@ -435,7 +344,7 @@ mod tests {
|
||||
"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]["content"],
|
||||
@@ -446,7 +355,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_tools() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"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]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"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];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
@@ -487,7 +394,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_result() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"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];
|
||||
assert_eq!(msg["role"], "tool");
|
||||
assert_eq!(msg["tool_call_id"], "call_123");
|
||||
@@ -563,78 +469,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_mapping_from_provider() {
|
||||
let provider = create_openrouter_provider();
|
||||
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();
|
||||
fn test_model_passthrough() {
|
||||
// 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理
|
||||
let input = json!({
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
||||
}
|
||||
|
||||
#[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");
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,8 @@ use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use reqwest::header::HeaderMap;
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
str::FromStr,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
@@ -128,7 +126,15 @@ pub async fn handle_non_streaming(
|
||||
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 {
|
||||
let model = json_value
|
||||
.get("model")
|
||||
@@ -140,6 +146,7 @@ pub async fn handle_non_streaming(
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
@@ -159,6 +166,7 @@ pub async fn handle_non_streaming(
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&ctx.request_model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
@@ -293,6 +301,7 @@ fn create_usage_collector(
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
@@ -300,6 +309,7 @@ fn create_usage_collector(
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -315,6 +325,7 @@ fn create_usage_collector(
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
@@ -322,6 +333,7 @@ fn create_usage_collector(
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
TokenUsage::default(),
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -342,6 +354,7 @@ fn spawn_log_usage(
|
||||
ctx: &RequestContext,
|
||||
usage: TokenUsage,
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
status_code: u16,
|
||||
is_streaming: bool,
|
||||
) {
|
||||
@@ -349,6 +362,7 @@ fn spawn_log_usage(
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let app_type_str = ctx.app_type_str.to_string();
|
||||
let model = model.to_string();
|
||||
let request_model = request_model.to_string();
|
||||
let latency_ms = ctx.latency_ms();
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
@@ -358,6 +372,7 @@ fn spawn_log_usage(
|
||||
&provider_id,
|
||||
&app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
@@ -376,6 +391,7 @@ async fn log_usage_internal(
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
usage: TokenUsage,
|
||||
latency_ms: u64,
|
||||
first_token_ms: Option<u64>,
|
||||
@@ -386,26 +402,12 @@ async fn log_usage_internal(
|
||||
use super::usage::logger::UsageLogger;
|
||||
|
||||
let logger = UsageLogger::new(&state.db);
|
||||
|
||||
// 获取 provider 的 cost_multiplier
|
||||
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
||||
Ok(Some(p)) => {
|
||||
if let Some(meta) = p.meta {
|
||||
if let Some(cm) = meta.cost_multiplier {
|
||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
||||
);
|
||||
Decimal::from(1)
|
||||
})
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
}
|
||||
_ => Decimal::from(1),
|
||||
let (multiplier, pricing_model_source) =
|
||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||
let pricing_model = if pricing_model_source == "request" {
|
||||
request_model
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -424,6 +426,8 @@ async fn log_usage_internal(
|
||||
provider_id.to_string(),
|
||||
app_type.to_string(),
|
||||
model.to_string(),
|
||||
request_model.to_string(),
|
||||
pricing_model.to_string(),
|
||||
usage,
|
||||
multiplier,
|
||||
latency_ms,
|
||||
@@ -556,3 +560,185 @@ fn format_headers(headers: &HeaderMap) -> String {
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::ProviderMeta;
|
||||
use crate::proxy::failover_switch::FailoverSwitchManager;
|
||||
use crate::proxy::provider_router::ProviderRouter;
|
||||
use crate::proxy::types::{ProxyConfig, ProxyStatus};
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
fn build_state(db: Arc<Database>) -> ProxyState {
|
||||
ProxyState {
|
||||
db: db.clone(),
|
||||
config: Arc::new(RwLock::new(ProxyConfig::default())),
|
||||
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||
start_time: Arc::new(RwLock::new(None)),
|
||||
current_providers: Arc::new(RwLock::new(HashMap::new())),
|
||||
provider_router: Arc::new(ProviderRouter::new(db.clone())),
|
||||
app_handle: None,
|
||||
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_pricing(db: &Database) -> Result<(), AppError> {
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params!["resp-model", "Resp Model", "1.0", "0"],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params!["req-model", "Req Model", "2.0", "0"],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_provider(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
app_type: &str,
|
||||
meta: ProviderMeta,
|
||||
) -> Result<(), AppError> {
|
||||
let meta_json =
|
||||
serde_json::to_string(&meta).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![id, app_type, "Test Provider", "{}", meta_json],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_usage_uses_provider_override_config() -> Result<(), AppError> {
|
||||
let db = Arc::new(Database::memory()?);
|
||||
let app_type = "claude";
|
||||
|
||||
db.set_default_cost_multiplier(app_type, "1.5").await?;
|
||||
db.set_pricing_model_source(app_type, "response").await?;
|
||||
seed_pricing(&db)?;
|
||||
|
||||
let mut meta = ProviderMeta::default();
|
||||
meta.cost_multiplier = Some("2".to_string());
|
||||
meta.pricing_model_source = Some("request".to_string());
|
||||
insert_provider(&db, "provider-1", app_type, meta)?;
|
||||
|
||||
let state = build_state(db.clone());
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
&state,
|
||||
"provider-1",
|
||||
app_type,
|
||||
"resp-model",
|
||||
"req-model",
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
false,
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (model, request_model, total_cost, cost_multiplier): (String, String, String, String) =
|
||||
conn.query_row(
|
||||
"SELECT model, request_model, total_cost_usd, cost_multiplier
|
||||
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||
["provider-1"],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
assert_eq!(model, "resp-model");
|
||||
assert_eq!(request_model, "req-model");
|
||||
assert_eq!(
|
||||
Decimal::from_str(&cost_multiplier).unwrap(),
|
||||
Decimal::from_str("2").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
Decimal::from_str(&total_cost).unwrap(),
|
||||
Decimal::from_str("4").unwrap()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
|
||||
let db = Arc::new(Database::memory()?);
|
||||
let app_type = "claude";
|
||||
|
||||
db.set_default_cost_multiplier(app_type, "1.5").await?;
|
||||
db.set_pricing_model_source(app_type, "response").await?;
|
||||
seed_pricing(&db)?;
|
||||
|
||||
let meta = ProviderMeta::default();
|
||||
insert_provider(&db, "provider-2", app_type, meta)?;
|
||||
|
||||
let state = build_state(db.clone());
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
&state,
|
||||
"provider-2",
|
||||
app_type,
|
||||
"resp-model",
|
||||
"req-model",
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
false,
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (total_cost, cost_multiplier): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT total_cost_usd, cost_multiplier
|
||||
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||
["provider-2"],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
assert_eq!(
|
||||
Decimal::from_str(&cost_multiplier).unwrap(),
|
||||
Decimal::from_str("1.5").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
Decimal::from_str(&total_cost).unwrap(),
|
||||
Decimal::from_str("1.5").unwrap()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ impl ProxyServer {
|
||||
|
||||
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
|
||||
|
||||
// 更新全局代理端口,用于系统代理检测
|
||||
crate::proxy::http_client::set_proxy_port(self.config.listen_port);
|
||||
|
||||
// 保存关闭句柄
|
||||
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ impl CostCalculator {
|
||||
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
|
||||
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
|
||||
/// - 这样避免缓存部分被重复计费
|
||||
/// - total_cost: 各项成本之和 × 倍率(倍率只作用于最终总价)
|
||||
pub fn calculate(
|
||||
usage: &TokenUsage,
|
||||
pricing: &ModelPricing,
|
||||
@@ -50,21 +51,20 @@ impl CostCalculator {
|
||||
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
|
||||
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
|
||||
|
||||
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
|
||||
/ million
|
||||
* cost_multiplier;
|
||||
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
|
||||
/ million
|
||||
* cost_multiplier;
|
||||
// 各项基础成本(不含倍率)
|
||||
let input_cost =
|
||||
Decimal::from(billable_input_tokens) * pricing.input_cost_per_million / million;
|
||||
let output_cost =
|
||||
Decimal::from(usage.output_tokens) * pricing.output_cost_per_million / million;
|
||||
let cache_read_cost =
|
||||
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million
|
||||
* cost_multiplier;
|
||||
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million;
|
||||
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
|
||||
* pricing.cache_creation_cost_per_million
|
||||
/ million
|
||||
* cost_multiplier;
|
||||
/ million;
|
||||
|
||||
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 {
|
||||
input_cost,
|
||||
@@ -151,8 +151,9 @@ mod tests {
|
||||
|
||||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||
|
||||
// input: 1000 * 3.0 / 1M * 1.5 = 0.0045
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap());
|
||||
// input_cost: 基础价格(不含倍率)= 1000 * 3.0 / 1M = 0.003
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
|
||||
// total_cost: 基础价格 × 倍率 = 0.003 * 1.5 = 0.0045
|
||||
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::services::usage_stats::find_model_pricing_row;
|
||||
use rust_decimal::Decimal;
|
||||
use std::time::SystemTime;
|
||||
use std::{str::FromStr, time::SystemTime};
|
||||
|
||||
/// 请求日志
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -15,6 +15,7 @@ pub struct RequestLog {
|
||||
pub provider_id: String,
|
||||
pub app_type: String,
|
||||
pub model: String,
|
||||
pub request_model: String,
|
||||
pub usage: TokenUsage,
|
||||
pub cost: Option<CostBreakdown>,
|
||||
pub latency_ms: u64,
|
||||
@@ -73,17 +74,18 @@ impl<'a> UsageLogger<'a> {
|
||||
|
||||
conn.execute(
|
||||
"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_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
|
||||
) 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![
|
||||
log.request_id,
|
||||
log.provider_id,
|
||||
log.app_type,
|
||||
log.model,
|
||||
log.request_model,
|
||||
log.usage.input_tokens,
|
||||
log.usage.output_tokens,
|
||||
log.usage.cache_read_tokens,
|
||||
@@ -123,11 +125,13 @@ impl<'a> UsageLogger<'a> {
|
||||
error_message: String,
|
||||
latency_ms: u64,
|
||||
) -> Result<(), AppError> {
|
||||
let request_model = model.clone();
|
||||
let log = RequestLog {
|
||||
request_id,
|
||||
provider_id,
|
||||
app_type,
|
||||
model,
|
||||
request_model,
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms,
|
||||
@@ -160,11 +164,13 @@ impl<'a> UsageLogger<'a> {
|
||||
session_id: Option<String>,
|
||||
provider_type: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let request_model = model.clone();
|
||||
let log = RequestLog {
|
||||
request_id,
|
||||
provider_id,
|
||||
app_type,
|
||||
model,
|
||||
request_model,
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms,
|
||||
@@ -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)]
|
||||
pub fn log_with_calculation(
|
||||
@@ -202,6 +290,8 @@ impl<'a> UsageLogger<'a> {
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
model: String,
|
||||
request_model: String,
|
||||
pricing_model: String,
|
||||
usage: TokenUsage,
|
||||
cost_multiplier: Decimal,
|
||||
latency_ms: u64,
|
||||
@@ -211,10 +301,10 @@ impl<'a> UsageLogger<'a> {
|
||||
provider_type: Option<String>,
|
||||
is_streaming: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let pricing = self.get_model_pricing(&model)?;
|
||||
let pricing = self.get_model_pricing(&pricing_model)?;
|
||||
|
||||
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);
|
||||
@@ -224,6 +314,7 @@ impl<'a> UsageLogger<'a> {
|
||||
provider_id,
|
||||
app_type,
|
||||
model,
|
||||
request_model,
|
||||
usage,
|
||||
cost,
|
||||
latency_ms,
|
||||
@@ -274,6 +365,8 @@ mod tests {
|
||||
"provider-1".to_string(),
|
||||
"claude".to_string(),
|
||||
"test-model".to_string(),
|
||||
"req-model".to_string(),
|
||||
"test-model".to_string(),
|
||||
usage,
|
||||
Decimal::from(1),
|
||||
100,
|
||||
@@ -286,14 +379,15 @@ mod tests {
|
||||
|
||||
// 验证记录已插入
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let count: i64 = conn
|
||||
let (count, request_model): (i64, String) = conn
|
||||
.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();
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(request_model, "req-model");
|
||||
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::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
@@ -181,7 +181,8 @@ impl ConfigService {
|
||||
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)?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||
|
||||
@@ -19,6 +19,18 @@ use super::gemini_auth::{
|
||||
};
|
||||
use super::normalize_claude_models_in_value;
|
||||
|
||||
pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
|
||||
let mut v = settings.clone();
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
// Internal-only fields - never write to Claude Code settings.json
|
||||
obj.remove("api_format");
|
||||
obj.remove("apiFormat");
|
||||
obj.remove("openrouter_compat_mode");
|
||||
obj.remove("openrouterCompatMode");
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Live configuration snapshot for backup/restore
|
||||
#[derive(Clone)]
|
||||
#[allow(dead_code)]
|
||||
@@ -97,7 +109,8 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
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 => {
|
||||
let obj = provider
|
||||
@@ -182,33 +195,67 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync all providers to live configuration (for additive mode apps)
|
||||
///
|
||||
/// Writes all providers from the database to the live configuration file.
|
||||
/// Used for OpenCode and other additive mode applications.
|
||||
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
for provider in providers.values() {
|
||||
if let Err(e) = write_live_snapshot(app_type, provider) {
|
||||
log::warn!(
|
||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||
app_type,
|
||||
provider.id
|
||||
);
|
||||
// Continue syncing other providers, don't abort
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Synced {} {:?} providers to live config",
|
||||
providers.len(),
|
||||
app_type
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration
|
||||
///
|
||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
|
||||
///
|
||||
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
|
||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
// Use validated effective current provider
|
||||
let current_id =
|
||||
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
// Sync providers based on mode
|
||||
for app_type in AppType::all() {
|
||||
if app_type.is_additive_mode() {
|
||||
// Additive mode: sync ALL providers
|
||||
sync_all_providers_to_live(state, &app_type)?;
|
||||
} else {
|
||||
// Switch mode: sync only current provider
|
||||
let current_id =
|
||||
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
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
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
// 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) {
|
||||
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
|
||||
// Continue syncing other apps, don't abort
|
||||
|
||||
@@ -26,6 +26,7 @@ pub use live::{
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
|
||||
// Internal re-exports
|
||||
|
||||
@@ -1654,7 +1654,8 @@ impl ProxyService {
|
||||
|
||||
fn write_claude_live(&self, config: &Value) -> Result<(), String> {
|
||||
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> {
|
||||
|
||||
@@ -1110,6 +1110,193 @@ impl SkillService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== 从 ZIP 文件安装 ==========
|
||||
|
||||
/// 从本地 ZIP 文件安装 Skills
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 解压 ZIP 到临时目录
|
||||
/// 2. 扫描目录查找包含 SKILL.md 的技能
|
||||
/// 3. 复制到 SSOT 并保存到数据库
|
||||
/// 4. 同步到当前应用目录
|
||||
pub fn install_from_zip(
|
||||
db: &Arc<Database>,
|
||||
zip_path: &Path,
|
||||
current_app: &AppType,
|
||||
) -> Result<Vec<InstalledSkill>> {
|
||||
// 解压到临时目录
|
||||
let temp_dir = Self::extract_local_zip(zip_path)?;
|
||||
|
||||
// 扫描所有包含 SKILL.md 的目录
|
||||
let skill_dirs = Self::scan_skills_in_dir(&temp_dir)?;
|
||||
|
||||
if skill_dirs.is_empty() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"NO_SKILLS_IN_ZIP",
|
||||
&[],
|
||||
Some("checkZipContent"),
|
||||
)));
|
||||
}
|
||||
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let mut installed = Vec::new();
|
||||
let existing_skills = db.get_all_installed_skills()?;
|
||||
|
||||
for skill_dir in skill_dirs {
|
||||
// 获取目录名称作为安装名
|
||||
let install_name = skill_dir
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// 检查是否已有同名 directory 的 skill
|
||||
let conflict = existing_skills
|
||||
.values()
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&install_name));
|
||||
|
||||
if let Some(existing) = conflict {
|
||||
log::warn!(
|
||||
"Skill directory '{}' already exists (from {}), skipping",
|
||||
install_name,
|
||||
existing.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析元数据
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
let (name, description) = if skill_md.exists() {
|
||||
match Self::parse_skill_metadata_static(&skill_md) {
|
||||
Ok(meta) => (
|
||||
meta.name.unwrap_or_else(|| install_name.clone()),
|
||||
meta.description,
|
||||
),
|
||||
Err(_) => (install_name.clone(), None),
|
||||
}
|
||||
} else {
|
||||
(install_name.clone(), None)
|
||||
};
|
||||
|
||||
// 复制到 SSOT
|
||||
let dest = ssot_dir.join(&install_name);
|
||||
if dest.exists() {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
}
|
||||
Self::copy_dir_recursive(&skill_dir, &dest)?;
|
||||
|
||||
// 创建 InstalledSkill 记录
|
||||
let skill = InstalledSkill {
|
||||
id: format!("local:{install_name}"),
|
||||
name,
|
||||
description,
|
||||
directory: install_name.clone(),
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps: SkillApps::only(current_app),
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
db.save_skill(&skill)?;
|
||||
|
||||
// 同步到当前应用目录
|
||||
Self::sync_to_app_dir(&install_name, current_app)?;
|
||||
|
||||
log::info!(
|
||||
"Skill {} installed from ZIP, enabled for {:?}",
|
||||
skill.name,
|
||||
current_app
|
||||
);
|
||||
installed.push(skill);
|
||||
}
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
/// 解压本地 ZIP 文件到临时目录
|
||||
fn extract_local_zip(zip_path: &Path) -> Result<PathBuf> {
|
||||
let file = fs::File::open(zip_path)
|
||||
.with_context(|| format!("Failed to open ZIP file: {}", zip_path.display()))?;
|
||||
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.with_context(|| format!("Failed to read ZIP file: {}", zip_path.display()))?;
|
||||
|
||||
if archive.is_empty() {
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"EMPTY_ARCHIVE",
|
||||
&[],
|
||||
Some("checkZipContent"),
|
||||
)));
|
||||
}
|
||||
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let file_path = match file.enclosed_name() {
|
||||
Some(path) => path.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let outpath = temp_path.join(&file_path);
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath)?;
|
||||
std::io::copy(&mut file, &mut outfile)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(temp_path)
|
||||
}
|
||||
|
||||
/// 递归扫描目录查找包含 SKILL.md 的技能目录
|
||||
fn scan_skills_in_dir(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut skill_dirs = Vec::new();
|
||||
Self::scan_skills_recursive(dir, &mut skill_dirs)?;
|
||||
Ok(skill_dirs)
|
||||
}
|
||||
|
||||
/// 递归扫描辅助函数
|
||||
fn scan_skills_recursive(current: &Path, results: &mut Vec<PathBuf>) -> Result<()> {
|
||||
// 检查当前目录是否包含 SKILL.md
|
||||
let skill_md = current.join("SKILL.md");
|
||||
if skill_md.exists() {
|
||||
results.push(current.to_path_buf());
|
||||
// 找到后不再递归子目录(一个 skill 目录)
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 递归子目录
|
||||
if let Ok(entries) = fs::read_dir(current) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// 跳过隐藏目录
|
||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||
if dir_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
Self::scan_skills_recursive(&path, results)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== 仓库管理(保留原有逻辑)==========
|
||||
|
||||
/// 列出仓库
|
||||
|
||||
@@ -373,11 +373,15 @@ impl StreamCheckService {
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/responses")
|
||||
// Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀),
|
||||
// Responses 端点为 `/responses`。
|
||||
//
|
||||
// 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。
|
||||
// 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。
|
||||
let urls = if base.ends_with("/v1") {
|
||||
vec![format!("{base}/responses")]
|
||||
} else {
|
||||
format!("{base}/v1/responses")
|
||||
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
||||
};
|
||||
|
||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
@@ -399,40 +403,50 @@ impl StreamCheckService {
|
||||
body["reasoning"] = json!({ "effort": effort });
|
||||
}
|
||||
|
||||
// 严格按照 Codex CLI 请求格式设置 headers
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.header(
|
||||
"user-agent",
|
||||
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
|
||||
)
|
||||
.header("originator", "codex_cli_rs")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Self::map_request_error)?;
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
// 严格按照 Codex CLI 请求格式设置 headers
|
||||
let response = client
|
||||
.post(url)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.header(
|
||||
"user-agent",
|
||||
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
|
||||
)
|
||||
.header("originator", "codex_cli_rs")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Self::map_request_error)?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let status = response.status().as_u16();
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
// 回退策略:仅当首选 URL 返回 404 时尝试下一个
|
||||
if i == 0 && status == 404 && urls.len() > 1 {
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||
}
|
||||
} 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 流式检查
|
||||
|
||||
@@ -94,6 +94,9 @@ pub struct RequestLogDetail {
|
||||
pub provider_name: Option<String>,
|
||||
pub app_type: String,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub request_model: Option<String>,
|
||||
pub cost_multiplier: String,
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
pub cache_read_tokens: u32,
|
||||
@@ -140,7 +143,7 @@ impl Database {
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
"SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
@@ -218,7 +221,7 @@ impl Database {
|
||||
}
|
||||
|
||||
let sql = "
|
||||
SELECT
|
||||
SELECT
|
||||
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
@@ -295,7 +298,7 @@ impl Database {
|
||||
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let sql = "SELECT
|
||||
let sql = "SELECT
|
||||
l.provider_id,
|
||||
p.name as provider_name,
|
||||
COUNT(*) as request_count,
|
||||
@@ -343,7 +346,7 @@ impl Database {
|
||||
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let sql = "SELECT
|
||||
let sql = "SELECT
|
||||
model,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
@@ -424,7 +427,7 @@ impl Database {
|
||||
|
||||
// 获取总数
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs l
|
||||
"SELECT COUNT(*) FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
{where_clause}"
|
||||
);
|
||||
@@ -440,6 +443,7 @@ impl Database {
|
||||
|
||||
let sql = format!(
|
||||
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
||||
l.request_model, l.cost_multiplier,
|
||||
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
|
||||
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
|
||||
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
|
||||
@@ -460,22 +464,26 @@ impl Database {
|
||||
provider_name: row.get(2)?,
|
||||
app_type: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
input_tokens: row.get::<_, i64>(5)? as u32,
|
||||
output_tokens: row.get::<_, i64>(6)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(7)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
|
||||
input_cost_usd: row.get(9)?,
|
||||
output_cost_usd: row.get(10)?,
|
||||
cache_read_cost_usd: row.get(11)?,
|
||||
cache_creation_cost_usd: row.get(12)?,
|
||||
total_cost_usd: row.get(13)?,
|
||||
is_streaming: row.get::<_, i64>(14)? != 0,
|
||||
latency_ms: row.get::<_, i64>(15)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(18)? as u16,
|
||||
error_message: row.get(19)?,
|
||||
created_at: row.get(20)?,
|
||||
request_model: row.get(5)?,
|
||||
cost_multiplier: row
|
||||
.get::<_, Option<String>>(6)?
|
||||
.unwrap_or_else(|| "1".to_string()),
|
||||
input_tokens: row.get::<_, i64>(7)? as u32,
|
||||
output_tokens: row.get::<_, i64>(8)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(9)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
|
||||
input_cost_usd: row.get(11)?,
|
||||
output_cost_usd: row.get(12)?,
|
||||
cache_read_cost_usd: row.get(13)?,
|
||||
cache_creation_cost_usd: row.get(14)?,
|
||||
total_cost_usd: row.get(15)?,
|
||||
is_streaming: row.get::<_, i64>(16)? != 0,
|
||||
latency_ms: row.get::<_, i64>(17)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(20)? as u16,
|
||||
error_message: row.get(21)?,
|
||||
created_at: row.get(22)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -511,6 +519,7 @@ impl Database {
|
||||
|
||||
let result = conn.query_row(
|
||||
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
||||
l.request_model, l.cost_multiplier,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
is_streaming, latency_ms, first_token_ms, duration_ms,
|
||||
@@ -526,22 +535,24 @@ impl Database {
|
||||
provider_name: row.get(2)?,
|
||||
app_type: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
input_tokens: row.get::<_, i64>(5)? as u32,
|
||||
output_tokens: row.get::<_, i64>(6)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(7)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
|
||||
input_cost_usd: row.get(9)?,
|
||||
output_cost_usd: row.get(10)?,
|
||||
cache_read_cost_usd: row.get(11)?,
|
||||
cache_creation_cost_usd: row.get(12)?,
|
||||
total_cost_usd: row.get(13)?,
|
||||
is_streaming: row.get::<_, i64>(14)? != 0,
|
||||
latency_ms: row.get::<_, i64>(15)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(18)? as u16,
|
||||
error_message: row.get(19)?,
|
||||
created_at: row.get(20)?,
|
||||
request_model: row.get(5)?,
|
||||
cost_multiplier: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "1".to_string()),
|
||||
input_tokens: row.get::<_, i64>(7)? as u32,
|
||||
output_tokens: row.get::<_, i64>(8)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(9)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
|
||||
input_cost_usd: row.get(11)?,
|
||||
output_cost_usd: row.get(12)?,
|
||||
cache_read_cost_usd: row.get(13)?,
|
||||
cache_creation_cost_usd: row.get(14)?,
|
||||
total_cost_usd: row.get(15)?,
|
||||
is_streaming: row.get::<_, i64>(16)? != 0,
|
||||
latency_ms: row.get::<_, i64>(17)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(20)? as u16,
|
||||
error_message: row.get(21)?,
|
||||
created_at: row.get(22)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -691,21 +702,26 @@ impl Database {
|
||||
)?;
|
||||
|
||||
let million = rust_decimal::Decimal::from(1_000_000u64);
|
||||
let input_cost = rust_decimal::Decimal::from(log.input_tokens as u64) * pricing.input
|
||||
/ million
|
||||
* multiplier;
|
||||
let output_cost = rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output
|
||||
/ million
|
||||
* multiplier;
|
||||
|
||||
// 与 CostCalculator::calculate 保持一致的计算逻辑:
|
||||
// 1. input_cost 需要扣除 cache_read_tokens(避免缓存部分被重复计费)
|
||||
// 2. 各项成本是基础成本(不含倍率)
|
||||
// 3. 倍率只作用于最终总价
|
||||
let billable_input_tokens =
|
||||
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64);
|
||||
let input_cost =
|
||||
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
|
||||
let output_cost =
|
||||
rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output / million;
|
||||
let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
|
||||
* pricing.cache_read
|
||||
/ million
|
||||
* multiplier;
|
||||
/ million;
|
||||
let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)
|
||||
* pricing.cache_creation
|
||||
/ million
|
||||
* multiplier;
|
||||
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||||
/ million;
|
||||
// 总成本 = 基础成本之和 × 倍率
|
||||
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.output_cost_usd = format!("{output_cost:.6}");
|
||||
|
||||
@@ -79,6 +79,9 @@ pub struct AppSettings {
|
||||
/// 是否开机自启
|
||||
#[serde(default)]
|
||||
pub launch_on_startup: bool,
|
||||
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
||||
#[serde(default)]
|
||||
pub silent_startup: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
|
||||
@@ -114,6 +117,14 @@ pub struct AppSettings {
|
||||
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||
#[serde(default)]
|
||||
pub skill_sync_method: SyncMethod,
|
||||
|
||||
// ===== 终端设置 =====
|
||||
/// 首选终端应用(可选,默认使用系统默认终端)
|
||||
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
|
||||
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_terminal: Option<String>,
|
||||
}
|
||||
|
||||
fn default_show_in_tray() -> bool {
|
||||
@@ -132,6 +143,7 @@ impl Default for AppSettings {
|
||||
enable_claude_plugin_integration: false,
|
||||
skip_claude_onboarding: false,
|
||||
launch_on_startup: false,
|
||||
silent_startup: false,
|
||||
language: None,
|
||||
visible_apps: None,
|
||||
claude_config_dir: None,
|
||||
@@ -143,6 +155,7 @@ impl Default for AppSettings {
|
||||
current_provider_gemini: None,
|
||||
current_provider_opencode: None,
|
||||
skill_sync_method: SyncMethod::default(),
|
||||
preferred_terminal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +163,11 @@ impl Default for AppSettings {
|
||||
impl AppSettings {
|
||||
fn settings_path() -> Option<PathBuf> {
|
||||
// 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) {
|
||||
@@ -402,3 +419,17 @@ pub fn get_skill_sync_method() -> SyncMethod {
|
||||
})
|
||||
.skill_sync_method
|
||||
}
|
||||
|
||||
// ===== 终端设置管理函数 =====
|
||||
|
||||
/// 获取首选终端应用
|
||||
pub fn get_preferred_terminal() -> Option<String> {
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.preferred_terminal
|
||||
.clone()
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
prefix: "claude_",
|
||||
header_id: "claude_header",
|
||||
empty_id: "claude_empty",
|
||||
header_label: "─── Claude ───",
|
||||
header_label: "Claude",
|
||||
log_name: "Claude",
|
||||
},
|
||||
TrayAppSection {
|
||||
@@ -71,7 +71,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
prefix: "codex_",
|
||||
header_id: "codex_header",
|
||||
empty_id: "codex_empty",
|
||||
header_label: "─── Codex ───",
|
||||
header_label: "Codex",
|
||||
log_name: "Codex",
|
||||
},
|
||||
TrayAppSection {
|
||||
@@ -79,7 +79,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
prefix: "gemini_",
|
||||
header_id: "gemini_header",
|
||||
empty_id: "gemini_empty",
|
||||
header_label: "─── Gemini ───",
|
||||
header_label: "Gemini",
|
||||
log_name: "Gemini",
|
||||
},
|
||||
];
|
||||
@@ -391,13 +391,16 @@ pub fn create_tray_menu(
|
||||
&tray_texts,
|
||||
app_state,
|
||||
)?;
|
||||
|
||||
// 在每个 section 后添加分隔符
|
||||
menu_builder = menu_builder.separator();
|
||||
}
|
||||
|
||||
// 分隔符和退出菜单
|
||||
// 退出菜单(分隔符已在上面的 section 循环中添加)
|
||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||
|
||||
menu_builder = menu_builder.separator().item(&quit_item);
|
||||
menu_builder = menu_builder.item(&quit_item);
|
||||
|
||||
menu_builder
|
||||
.build()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.10.2",
|
||||
"version": "3.10.3",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -971,12 +971,18 @@ fn export_sql_returns_error_for_invalid_path() {
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
// Try to export to an invalid path (parent directory doesn't exist)
|
||||
let invalid_path = PathBuf::from("/nonexistent/directory/export.sql");
|
||||
// Try to export to an invalid path (nonexistent parent or invalid name on Windows)
|
||||
let invalid_parent = if cfg!(windows) {
|
||||
std::env::temp_dir().join("cc-switch-test-invalid<>dir")
|
||||
} else {
|
||||
PathBuf::from("/nonexistent/directory")
|
||||
};
|
||||
let invalid_path = invalid_parent.join("export.sql");
|
||||
let err = state
|
||||
.db
|
||||
.export_sql(&invalid_path)
|
||||
.expect_err("export to invalid path should fail");
|
||||
let invalid_prefix = invalid_parent.to_string_lossy();
|
||||
|
||||
// The error can be either IoContext or Io depending on where it fails
|
||||
match err {
|
||||
@@ -988,8 +994,8 @@ fn export_sql_returns_error_for_invalid_path() {
|
||||
}
|
||||
AppError::Io { path, .. } => {
|
||||
assert!(
|
||||
path.starts_with("/nonexistent"),
|
||||
"expected error for /nonexistent path, got: {path:?}"
|
||||
path.starts_with(invalid_prefix.as_ref()),
|
||||
"expected error for {invalid_parent:?}, got: {path:?}"
|
||||
);
|
||||
}
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -15,6 +15,7 @@ import {
|
||||
Search,
|
||||
Download,
|
||||
BarChart2,
|
||||
FolderArchive,
|
||||
} from "lucide-react";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
@@ -635,6 +636,7 @@ function App() {
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentView}
|
||||
className="flex-1 min-h-0"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@@ -828,6 +830,17 @@ function App() {
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
||||
}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<FolderArchive className="w-4 h-4 mr-2" />
|
||||
{t("skills.installFromZip.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -959,8 +972,8 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 pb-12 animate-fade-in ">
|
||||
<div className="pb-12">{renderContent()}</div>
|
||||
<main className="flex-1 min-h-0 flex flex-col animate-fade-in">
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
<AddProviderDialog
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
|
||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||
|
||||
interface EndpointCandidate {
|
||||
@@ -59,10 +65,9 @@ interface ClaudeFormFieldsProps {
|
||||
// Speed Test Endpoints
|
||||
speedTestEndpoints: EndpointCandidate[];
|
||||
|
||||
// OpenRouter Compat
|
||||
showOpenRouterCompatToggle: boolean;
|
||||
openRouterCompatEnabled: boolean;
|
||||
onOpenRouterCompatChange: (enabled: boolean) => void;
|
||||
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||
apiFormat: ClaudeApiFormat;
|
||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -95,9 +100,8 @@ export function ClaudeFormFields({
|
||||
defaultOpusModel,
|
||||
onModelChange,
|
||||
speedTestEndpoints,
|
||||
showOpenRouterCompatToggle,
|
||||
openRouterCompatEnabled,
|
||||
onOpenRouterCompatChange,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -180,25 +184,34 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOpenRouterCompatToggle && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("providerForm.openrouterCompatMode", {
|
||||
defaultValue: "OpenRouter 兼容模式",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.openrouterCompatModeHint", {
|
||||
defaultValue:
|
||||
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={openRouterCompatEnabled}
|
||||
onCheckedChange={onOpenRouterCompatChange}
|
||||
/>
|
||||
{/* API 格式选择(仅非官方供应商显示) */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</FormLabel>
|
||||
<Select value={apiFormat} onValueChange={onApiFormatChange}>
|
||||
<SelectTrigger id="apiFormat" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">
|
||||
{t("providerForm.apiFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.apiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.apiFormatHint", {
|
||||
defaultValue: "选择供应商 API 的输入格式",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ interface OpenCodeFormFieldsProps {
|
||||
category?: ProviderCategory;
|
||||
shouldShowApiKeyLink: boolean;
|
||||
websiteUrl: string;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
// Base URL
|
||||
baseUrl: string;
|
||||
@@ -161,6 +163,8 @@ export function OpenCodeFormFields({
|
||||
category,
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
baseUrl,
|
||||
onBaseUrlChange,
|
||||
models,
|
||||
@@ -376,6 +380,8 @@ export function OpenCodeFormFields({
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
/>
|
||||
|
||||
{/* Base URL */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChevronRight,
|
||||
FlaskConical,
|
||||
Globe,
|
||||
Coins,
|
||||
Eye,
|
||||
EyeOff,
|
||||
X,
|
||||
@@ -13,14 +14,31 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types";
|
||||
|
||||
export type PricingModelSourceOption = "inherit" | "request" | "response";
|
||||
|
||||
interface ProviderPricingConfig {
|
||||
enabled: boolean;
|
||||
costMultiplier?: string;
|
||||
pricingModelSource: PricingModelSourceOption;
|
||||
}
|
||||
|
||||
interface ProviderAdvancedConfigProps {
|
||||
testConfig: ProviderTestConfig;
|
||||
proxyConfig: ProviderProxyConfig;
|
||||
pricingConfig: ProviderPricingConfig;
|
||||
onTestConfigChange: (config: ProviderTestConfig) => void;
|
||||
onProxyConfigChange: (config: ProviderProxyConfig) => void;
|
||||
onPricingConfigChange: (config: ProviderPricingConfig) => void;
|
||||
}
|
||||
|
||||
/** 从 ProviderProxyConfig 构建完整 URL */
|
||||
@@ -71,14 +89,19 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
|
||||
export function ProviderAdvancedConfig({
|
||||
testConfig,
|
||||
proxyConfig,
|
||||
pricingConfig,
|
||||
onTestConfigChange,
|
||||
onProxyConfigChange,
|
||||
onPricingConfigChange,
|
||||
}: ProviderAdvancedConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
||||
proxyConfig.enabled,
|
||||
);
|
||||
const [isPricingConfigOpen, setIsPricingConfigOpen] = useState(
|
||||
pricingConfig.enabled,
|
||||
);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建)
|
||||
@@ -97,6 +120,11 @@ export function ProviderAdvancedConfig({
|
||||
setIsProxyConfigOpen(proxyConfig.enabled);
|
||||
}, [proxyConfig.enabled]);
|
||||
|
||||
// 同步外部 pricingConfig.enabled 变化到展开状态
|
||||
useEffect(() => {
|
||||
setIsPricingConfigOpen(pricingConfig.enabled);
|
||||
}, [pricingConfig.enabled]);
|
||||
|
||||
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
|
||||
useEffect(() => {
|
||||
if (!isUserTyping) {
|
||||
@@ -450,6 +478,143 @@ export function ProviderAdvancedConfig({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ProviderMeta,
|
||||
ProviderTestConfig,
|
||||
ProviderProxyConfig,
|
||||
ClaudeApiFormat,
|
||||
} from "@/types";
|
||||
import {
|
||||
providerPresets,
|
||||
@@ -46,7 +47,10 @@ import { BasicFormFields } from "./BasicFormFields";
|
||||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||
import { CodexFormFields } from "./CodexFormFields";
|
||||
import { GeminiFormFields } from "./GeminiFormFields";
|
||||
import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig";
|
||||
import {
|
||||
ProviderAdvancedConfig,
|
||||
type PricingModelSourceOption,
|
||||
} from "./ProviderAdvancedConfig";
|
||||
import {
|
||||
useProviderCategory,
|
||||
useApiKeyState,
|
||||
@@ -121,6 +125,9 @@ interface ProviderFormProps {
|
||||
showButtons?: boolean;
|
||||
}
|
||||
|
||||
const normalizePricingSource = (value?: string): PricingModelSourceOption =>
|
||||
value === "request" || value === "response" ? value : "inherit";
|
||||
|
||||
export function ProviderForm({
|
||||
appId,
|
||||
providerId,
|
||||
@@ -168,6 +175,19 @@ export function ProviderForm({
|
||||
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
|
||||
() => initialData?.meta?.proxyConfig ?? { enabled: false },
|
||||
);
|
||||
const [pricingConfig, setPricingConfig] = useState<{
|
||||
enabled: boolean;
|
||||
costMultiplier?: string;
|
||||
pricingModelSource: PricingModelSourceOption;
|
||||
}>(() => ({
|
||||
enabled:
|
||||
initialData?.meta?.costMultiplier !== undefined ||
|
||||
initialData?.meta?.pricingModelSource !== undefined,
|
||||
costMultiplier: initialData?.meta?.costMultiplier,
|
||||
pricingModelSource: normalizePricingSource(
|
||||
initialData?.meta?.pricingModelSource,
|
||||
),
|
||||
}));
|
||||
|
||||
// 使用 category hook
|
||||
const { category } = useProviderCategory({
|
||||
@@ -188,6 +208,15 @@ export function ProviderForm({
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
|
||||
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
|
||||
setPricingConfig({
|
||||
enabled:
|
||||
initialData?.meta?.costMultiplier !== undefined ||
|
||||
initialData?.meta?.pricingModelSource !== undefined,
|
||||
costMultiplier: initialData?.meta?.costMultiplier,
|
||||
pricingModelSource: normalizePricingSource(
|
||||
initialData?.meta?.pricingModelSource,
|
||||
),
|
||||
});
|
||||
}, [appId, initialData]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -216,8 +245,6 @@ export function ProviderForm({
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
const settingsConfigValue = form.getValues("settingsConfig");
|
||||
|
||||
// 使用 API Key hook
|
||||
const {
|
||||
apiKey,
|
||||
@@ -256,52 +283,16 @@ export function ProviderForm({
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
});
|
||||
|
||||
const isOpenRouterProvider = useMemo(() => {
|
||||
if (appId !== "claude") return false;
|
||||
const normalized = baseUrl.trim().toLowerCase();
|
||||
if (normalized.includes("openrouter.ai")) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const config = JSON.parse(settingsConfigValue || "{}");
|
||||
const envUrl = config?.env?.ANTHROPIC_BASE_URL;
|
||||
return typeof envUrl === "string" && envUrl.includes("openrouter.ai");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [appId, baseUrl, settingsConfigValue]);
|
||||
// Claude API Format state - stored in meta, not settingsConfig
|
||||
// Read initial value from meta.apiFormat, default to "anthropic"
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
return initialData?.meta?.apiFormat ?? "anthropic";
|
||||
});
|
||||
|
||||
const openRouterCompatEnabled = useMemo(() => {
|
||||
if (!isOpenRouterProvider) return false;
|
||||
try {
|
||||
const config = JSON.parse(settingsConfigValue || "{}");
|
||||
const raw = config?.openrouter_compat_mode;
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "number") return raw !== 0;
|
||||
if (typeof raw === "string") {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false; // OpenRouter now supports Claude Code compatible API, no need for transform
|
||||
}, [isOpenRouterProvider, settingsConfigValue]);
|
||||
|
||||
const handleOpenRouterCompatChange = useCallback(
|
||||
(enabled: boolean) => {
|
||||
try {
|
||||
const currentConfig = JSON.parse(
|
||||
form.getValues("settingsConfig") || "{}",
|
||||
);
|
||||
currentConfig.openrouter_compat_mode = enabled;
|
||||
form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||
setLocalApiFormat(format);
|
||||
}, []);
|
||||
|
||||
// 使用 Codex 配置 hook (仅 Codex 模式)
|
||||
const {
|
||||
@@ -940,6 +931,18 @@ export function ProviderForm({
|
||||
// 添加高级配置
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
|
||||
costMultiplier: pricingConfig.enabled
|
||||
? pricingConfig.costMultiplier
|
||||
: undefined,
|
||||
pricingModelSource:
|
||||
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
|
||||
? pricingConfig.pricingModelSource
|
||||
: undefined,
|
||||
// Claude API 格式(仅非官方 Claude 供应商使用)
|
||||
apiFormat:
|
||||
appId === "claude" && category !== "official"
|
||||
? localApiFormat
|
||||
: undefined,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
@@ -1007,6 +1010,20 @@ export function ProviderForm({
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用 API Key 链接 hook (OpenCode)
|
||||
const {
|
||||
shouldShowApiKeyLink: shouldShowOpencodeApiKeyLink,
|
||||
websiteUrl: opencodeWebsiteUrl,
|
||||
isPartner: isOpencodePartner,
|
||||
partnerPromotionKey: opencodePartnerPromotionKey,
|
||||
} = useApiKeyLink({
|
||||
appId: "opencode",
|
||||
category,
|
||||
selectedPresetId,
|
||||
presetEntries,
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用端点测速候选 hook
|
||||
const speedTestEndpoints = useSpeedTestEndpoints({
|
||||
appId,
|
||||
@@ -1136,6 +1153,14 @@ export function ProviderForm({
|
||||
preset.templateValues,
|
||||
);
|
||||
|
||||
// Sync preset's apiFormat to local state (for Claude providers)
|
||||
if (preset.apiFormat) {
|
||||
setLocalApiFormat(preset.apiFormat);
|
||||
} else {
|
||||
// Reset to default if preset doesn't specify apiFormat
|
||||
setLocalApiFormat("anthropic");
|
||||
}
|
||||
|
||||
form.reset({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
@@ -1259,9 +1284,8 @@ export function ProviderForm({
|
||||
defaultOpusModel={defaultOpusModel}
|
||||
onModelChange={handleModelChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
showOpenRouterCompatToggle={false}
|
||||
openRouterCompatEnabled={openRouterCompatEnabled}
|
||||
onOpenRouterCompatChange={handleOpenRouterCompatChange}
|
||||
apiFormat={localApiFormat}
|
||||
onApiFormatChange={handleApiFormatChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1331,8 +1355,10 @@ export function ProviderForm({
|
||||
apiKey={opencodeApiKey}
|
||||
onApiKeyChange={handleOpencodeApiKeyChange}
|
||||
category={category}
|
||||
shouldShowApiKeyLink={false}
|
||||
websiteUrl=""
|
||||
shouldShowApiKeyLink={shouldShowOpencodeApiKeyLink}
|
||||
websiteUrl={opencodeWebsiteUrl}
|
||||
isPartner={isOpencodePartner}
|
||||
partnerPromotionKey={opencodePartnerPromotionKey}
|
||||
baseUrl={opencodeBaseUrl}
|
||||
onBaseUrlChange={handleOpencodeBaseUrlChange}
|
||||
models={opencodeModels}
|
||||
@@ -1464,8 +1490,10 @@ export function ProviderForm({
|
||||
<ProviderAdvancedConfig
|
||||
testConfig={testConfig}
|
||||
proxyConfig={proxyConfig}
|
||||
pricingConfig={pricingConfig}
|
||||
onTestConfigChange={setTestConfig}
|
||||
onProxyConfigChange={setProxyConfig}
|
||||
onPricingConfigChange={setPricingConfig}
|
||||
/>
|
||||
|
||||
{showButtons && (
|
||||
|
||||
@@ -4,10 +4,15 @@ import type { ProviderCategory } from "@/types";
|
||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||
import type { OpenCodeProviderPreset } from "@/config/opencodeProviderPresets";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
|
||||
preset:
|
||||
| ProviderPreset
|
||||
| CodexProviderPreset
|
||||
| GeminiProviderPreset
|
||||
| OpenCodeProviderPreset;
|
||||
};
|
||||
|
||||
interface UseApiKeyLinkProps {
|
||||
@@ -74,7 +79,10 @@ export function useApiKeyLink({
|
||||
|
||||
return {
|
||||
shouldShowApiKeyLink:
|
||||
appId === "claude" || appId === "codex" || appId === "gemini"
|
||||
appId === "claude" ||
|
||||
appId === "codex" ||
|
||||
appId === "gemini" ||
|
||||
appId === "opencode"
|
||||
? shouldShowApiKeyLink
|
||||
: false,
|
||||
websiteUrl: getWebsiteUrl,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AppId } from "@/lib/api";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
import { opencodeProviderPresets } from "@/config/opencodeProviderPresets";
|
||||
|
||||
interface UseProviderCategoryProps {
|
||||
appId: AppId;
|
||||
@@ -42,7 +43,9 @@ export function useProviderCategory({
|
||||
if (!selectedPresetId) return;
|
||||
|
||||
// 从预设 ID 提取索引
|
||||
const match = selectedPresetId.match(/^(claude|codex|gemini)-(\d+)$/);
|
||||
const match = selectedPresetId.match(
|
||||
/^(claude|codex|gemini|opencode)-(\d+)$/,
|
||||
);
|
||||
if (!match) return;
|
||||
|
||||
const [, type, indexStr] = match;
|
||||
@@ -67,6 +70,11 @@ export function useProviderCategory({
|
||||
if (preset) {
|
||||
setCategory(preset.category || undefined);
|
||||
}
|
||||
} else if (type === "opencode" && appId === "opencode") {
|
||||
const preset = opencodeProviderPresets[index];
|
||||
if (preset) {
|
||||
setCategory(preset.category || undefined);
|
||||
}
|
||||
}
|
||||
}, [appId, selectedPresetId, isEditMode, initialCategory]);
|
||||
|
||||
|
||||
@@ -32,9 +32,14 @@ interface ToolVersion {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const ONE_CLICK_INSTALL_COMMANDS = `npm i -g @anthropic-ai/claude-code@latest
|
||||
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
# Codex
|
||||
npm i -g @openai/codex@latest
|
||||
npm i -g @google/gemini-cli@latest`;
|
||||
# Gemini CLI
|
||||
npm i -g @google/gemini-cli@latest
|
||||
# OpenCode
|
||||
curl -fsSL https://opencode.ai/install | bash`;
|
||||
|
||||
export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
// ... (use hooks as before) ...
|
||||
@@ -312,10 +317,14 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 px-1">
|
||||
{["claude", "codex", "gemini"].map((toolName, index) => {
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
|
||||
{["claude", "codex", "gemini", "opencode"].map((toolName, index) => {
|
||||
const tool = toolVersions.find((item) => item.name === toolName);
|
||||
const displayName = tool?.name ?? toolName;
|
||||
// Special case for OpenCode (capital C), others use capitalize
|
||||
const displayName =
|
||||
toolName === "opencode"
|
||||
? "OpenCode"
|
||||
: toolName.charAt(0).toUpperCase() + toolName.slice(1);
|
||||
const title = tool?.version || tool?.error || t("common.unknown");
|
||||
|
||||
return (
|
||||
@@ -330,9 +339,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{displayName}</span>
|
||||
</div>
|
||||
{isLoadingTools ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -31,7 +31,7 @@ export function AppVisibilitySettings({
|
||||
const visibleApps: VisibleApps = settings.visibleApps ?? {
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ interface DirectorySettingsProps {
|
||||
claudeDir?: string;
|
||||
codexDir?: string;
|
||||
geminiDir?: string;
|
||||
opencodeDir?: string;
|
||||
onDirectoryChange: (app: AppId, value?: string) => void;
|
||||
onBrowseDirectory: (app: AppId) => Promise<void>;
|
||||
onResetDirectory: (app: AppId) => Promise<void>;
|
||||
@@ -29,6 +30,7 @@ export function DirectorySettings({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
geminiDir,
|
||||
opencodeDir,
|
||||
onDirectoryChange,
|
||||
onBrowseDirectory,
|
||||
onResetDirectory,
|
||||
@@ -117,6 +119,17 @@ export function DirectorySettings({
|
||||
onBrowse={() => onBrowseDirectory("gemini")}
|
||||
onReset={() => onResetDirectory("gemini")}
|
||||
/>
|
||||
|
||||
<DirectoryInput
|
||||
label={t("settings.opencodeConfigDir")}
|
||||
description={undefined}
|
||||
value={opencodeDir}
|
||||
resolvedValue={resolvedDirs.opencode}
|
||||
placeholder={t("settings.browsePlaceholderOpencode")}
|
||||
onChange={(val) => onDirectoryChange("opencode", val)}
|
||||
onBrowse={() => onBrowseDirectory("opencode")}
|
||||
onReset={() => onResetDirectory("opencode")}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ThemeSettings } from "@/components/settings/ThemeSettings";
|
||||
import { WindowSettings } from "@/components/settings/WindowSettings";
|
||||
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
|
||||
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
|
||||
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
@@ -206,7 +207,7 @@ export function SettingsPage({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
||||
<div className="flex flex-col h-full overflow-hidden px-6">
|
||||
{isBusy ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
@@ -228,420 +229,432 @@ export function SettingsPage({
|
||||
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
||||
<TabsContent value="general" className="space-y-6 mt-0">
|
||||
{settings ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<LanguageSettings
|
||||
value={settings.language}
|
||||
onChange={(lang) => handleAutoSave({ language: lang })}
|
||||
/>
|
||||
<ThemeSettings />
|
||||
<AppVisibilitySettings
|
||||
settings={settings}
|
||||
onChange={handleAutoSave}
|
||||
/>
|
||||
<WindowSettings
|
||||
settings={settings}
|
||||
onChange={handleAutoSave}
|
||||
/>
|
||||
<SkillSyncMethodSettings
|
||||
value={settings.skillSyncMethod ?? "auto"}
|
||||
onChange={(method) =>
|
||||
handleAutoSave({ skillSyncMethod: method })
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
|
||||
{settings ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={[]}
|
||||
className="w-full space-y-4"
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
||||
<TabsContent value="general" className="space-y-6 mt-0">
|
||||
{settings ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<AccordionItem
|
||||
value="directory"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderSearch className="h-5 w-5 text-primary" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.configDir.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.configDir.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<DirectorySettings
|
||||
appConfigDir={appConfigDir}
|
||||
resolvedDirs={resolvedDirs}
|
||||
onAppConfigChange={updateAppConfigDir}
|
||||
onBrowseAppConfig={browseAppConfigDir}
|
||||
onResetAppConfig={resetAppConfigDir}
|
||||
claudeDir={settings.claudeConfigDir}
|
||||
codexDir={settings.codexConfigDir}
|
||||
geminiDir={settings.geminiConfigDir}
|
||||
onDirectoryChange={updateDirectory}
|
||||
onBrowseDirectory={browseDirectory}
|
||||
onResetDirectory={resetDirectory}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<LanguageSettings
|
||||
value={settings.language}
|
||||
onChange={(lang) => handleAutoSave({ language: lang })}
|
||||
/>
|
||||
<ThemeSettings />
|
||||
<AppVisibilitySettings
|
||||
settings={settings}
|
||||
onChange={handleAutoSave}
|
||||
/>
|
||||
<WindowSettings
|
||||
settings={settings}
|
||||
onChange={handleAutoSave}
|
||||
/>
|
||||
<SkillSyncMethodSettings
|
||||
value={settings.skillSyncMethod ?? "auto"}
|
||||
onChange={(method) =>
|
||||
handleAutoSave({ skillSyncMethod: method })
|
||||
}
|
||||
/>
|
||||
<TerminalSettings
|
||||
value={settings.preferredTerminal}
|
||||
onChange={(terminal) =>
|
||||
handleAutoSave({ preferredTerminal: terminal })
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<AccordionItem
|
||||
value="proxy"
|
||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
|
||||
{settings ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={[]}
|
||||
className="w-full space-y-4"
|
||||
>
|
||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
||||
<AccordionItem
|
||||
value="directory"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-green-500" />
|
||||
<FolderSearch className="h-5 w-5 text-primary" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.proxy.title")}
|
||||
{t("settings.advanced.configDir.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.proxy.description")}
|
||||
{t("settings.advanced.configDir.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
|
||||
<div className="flex items-center gap-4 pl-4">
|
||||
<Badge
|
||||
variant={isRunning ? "default" : "secondary"}
|
||||
className="gap-1.5 h-6"
|
||||
>
|
||||
<Activity
|
||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{isRunning
|
||||
? t("settings.advanced.proxy.running")
|
||||
: t("settings.advanced.proxy.stopped")}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={isRunning}
|
||||
onCheckedChange={handleToggleProxy}
|
||||
disabled={isProxyPending}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<DirectorySettings
|
||||
appConfigDir={appConfigDir}
|
||||
resolvedDirs={resolvedDirs}
|
||||
onAppConfigChange={updateAppConfigDir}
|
||||
onBrowseAppConfig={browseAppConfigDir}
|
||||
onResetAppConfig={resetAppConfigDir}
|
||||
claudeDir={settings.claudeConfigDir}
|
||||
codexDir={settings.codexConfigDir}
|
||||
geminiDir={settings.geminiConfigDir}
|
||||
opencodeDir={settings.opencodeConfigDir}
|
||||
onDirectoryChange={updateDirectory}
|
||||
onBrowseDirectory={browseDirectory}
|
||||
onResetDirectory={resetDirectory}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
|
||||
<ProxyPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="failover"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.failover.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.failover.description")}
|
||||
</p>
|
||||
<AccordionItem
|
||||
value="proxy"
|
||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
||||
>
|
||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-green-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.proxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.proxy.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
|
||||
<div className="flex items-center gap-4 pl-4">
|
||||
<Badge
|
||||
variant={isRunning ? "default" : "secondary"}
|
||||
className="gap-1.5 h-6"
|
||||
>
|
||||
<Activity
|
||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{isRunning
|
||||
? t("settings.advanced.proxy.running")
|
||||
: t("settings.advanced.proxy.stopped")}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={isRunning}
|
||||
onCheckedChange={handleToggleProxy}
|
||||
disabled={isProxyPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<div className="space-y-6">
|
||||
{/* 代理未运行时的提示 */}
|
||||
{!isRunning && (
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t("proxy.failover.proxyRequired", {
|
||||
defaultValue:
|
||||
"需要先启动代理服务才能配置故障转移",
|
||||
})}
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
|
||||
<ProxyPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="failover"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.failover.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.failover.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<div className="space-y-6">
|
||||
{/* 代理未运行时的提示 */}
|
||||
{!isRunning && (
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t("proxy.failover.proxyRequired", {
|
||||
defaultValue:
|
||||
"需要先启动代理服务才能配置故障转移",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 故障转移设置 - 按应用分组 */}
|
||||
<Tabs defaultValue="claude" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="claude">Claude</TabsTrigger>
|
||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="claude"
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
{/* 故障转移设置 - 按应用分组 */}
|
||||
<Tabs defaultValue="claude" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="claude">Claude</TabsTrigger>
|
||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="claude"
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="codex"
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gemini"
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="codex"
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="rectifier"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.rectifier.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.rectifier.description")}
|
||||
</p>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gemini"
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<RectifierConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="test"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-indigo-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.modelTest.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.modelTest.description")}
|
||||
</p>
|
||||
<AccordionItem
|
||||
value="rectifier"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.rectifier.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.rectifier.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<ModelTestConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<RectifierConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="pricing"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Coins className="h-5 w-5 text-yellow-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.pricing.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.pricing.description")}
|
||||
</p>
|
||||
<AccordionItem
|
||||
value="test"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-indigo-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.modelTest.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.modelTest.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<PricingConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<ModelTestConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="globalProxy"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-5 w-5 text-cyan-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.globalProxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.globalProxy.description")}
|
||||
</p>
|
||||
<AccordionItem
|
||||
value="pricing"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Coins className="h-5 w-5 text-yellow-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.pricing.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.pricing.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<GlobalProxySettings />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<PricingConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="data"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5 text-blue-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.data.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.data.description")}
|
||||
</p>
|
||||
<AccordionItem
|
||||
value="globalProxy"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-5 w-5 text-cyan-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.globalProxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.globalProxy.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<ImportExportSection
|
||||
status={importStatus}
|
||||
selectedFile={selectedFile}
|
||||
errorMessage={errorMessage}
|
||||
backupId={backupId}
|
||||
isImporting={isImporting}
|
||||
onSelectFile={selectImportFile}
|
||||
onImport={importConfig}
|
||||
onExport={exportConfig}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<GlobalProxySettings />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="logConfig"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<ScrollText className="h-5 w-5 text-cyan-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.logConfig.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.logConfig.description")}
|
||||
</p>
|
||||
<AccordionItem
|
||||
value="data"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5 text-blue-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.data.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.data.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<LogConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<ImportExportSection
|
||||
status={importStatus}
|
||||
selectedFile={selectedFile}
|
||||
errorMessage={errorMessage}
|
||||
backupId={backupId}
|
||||
isImporting={isImporting}
|
||||
onSelectFile={selectImportFile}
|
||||
onImport={importConfig}
|
||||
onExport={exportConfig}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="w-full h-12 text-base font-medium"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t("settings.saving")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
{t("common.save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<AccordionItem
|
||||
value="logConfig"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<ScrollText className="h-5 w-5 text-cyan-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.logConfig.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.logConfig.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<LogConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="about" className="mt-0">
|
||||
<AboutSection isPortable={isPortable} />
|
||||
</TabsContent>
|
||||
<TabsContent value="about" className="mt-0">
|
||||
<AboutSection isPortable={isPortable} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usage" className="mt-0">
|
||||
<UsageDashboard />
|
||||
</TabsContent>
|
||||
<TabsContent value="usage" className="mt-0">
|
||||
<UsageDashboard />
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{activeTab === "advanced" && settings && (
|
||||
<div
|
||||
className="flex-shrink-0 py-4 border-t border-border-default"
|
||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||
>
|
||||
<div className="px-6 flex items-center justify-end gap-3">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.saving")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("common.save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isMac, isWindows, isLinux } from "@/lib/platform";
|
||||
|
||||
// Terminal options per platform
|
||||
const MACOS_TERMINALS = [
|
||||
{ value: "terminal", labelKey: "settings.terminal.options.macos.terminal" },
|
||||
{ value: "iterm2", labelKey: "settings.terminal.options.macos.iterm2" },
|
||||
{ value: "alacritty", labelKey: "settings.terminal.options.macos.alacritty" },
|
||||
{ value: "kitty", labelKey: "settings.terminal.options.macos.kitty" },
|
||||
{ value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" },
|
||||
] as const;
|
||||
|
||||
const WINDOWS_TERMINALS = [
|
||||
{ value: "cmd", labelKey: "settings.terminal.options.windows.cmd" },
|
||||
{
|
||||
value: "powershell",
|
||||
labelKey: "settings.terminal.options.windows.powershell",
|
||||
},
|
||||
{ value: "wt", labelKey: "settings.terminal.options.windows.wt" },
|
||||
] as const;
|
||||
|
||||
const LINUX_TERMINALS = [
|
||||
{
|
||||
value: "gnome-terminal",
|
||||
labelKey: "settings.terminal.options.linux.gnomeTerminal",
|
||||
},
|
||||
{ value: "konsole", labelKey: "settings.terminal.options.linux.konsole" },
|
||||
{
|
||||
value: "xfce4-terminal",
|
||||
labelKey: "settings.terminal.options.linux.xfce4Terminal",
|
||||
},
|
||||
{ value: "alacritty", labelKey: "settings.terminal.options.linux.alacritty" },
|
||||
{ value: "kitty", labelKey: "settings.terminal.options.linux.kitty" },
|
||||
{ value: "ghostty", labelKey: "settings.terminal.options.linux.ghostty" },
|
||||
] as const;
|
||||
|
||||
// Get terminals for the current platform
|
||||
function getTerminalOptions() {
|
||||
if (isMac()) {
|
||||
return MACOS_TERMINALS;
|
||||
}
|
||||
if (isWindows()) {
|
||||
return WINDOWS_TERMINALS;
|
||||
}
|
||||
if (isLinux()) {
|
||||
return LINUX_TERMINALS;
|
||||
}
|
||||
// Fallback to macOS options
|
||||
return MACOS_TERMINALS;
|
||||
}
|
||||
|
||||
// Get default terminal for the current platform
|
||||
function getDefaultTerminal(): string {
|
||||
if (isMac()) {
|
||||
return "terminal";
|
||||
}
|
||||
if (isWindows()) {
|
||||
return "cmd";
|
||||
}
|
||||
if (isLinux()) {
|
||||
return "gnome-terminal";
|
||||
}
|
||||
return "terminal";
|
||||
}
|
||||
|
||||
export interface TerminalSettingsProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function TerminalSettings({ value, onChange }: TerminalSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const terminals = getTerminalOptions();
|
||||
const defaultTerminal = getDefaultTerminal();
|
||||
|
||||
// Use value or default
|
||||
const currentValue = value || defaultTerminal;
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("settings.terminal.title")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.description")}
|
||||
</p>
|
||||
</header>
|
||||
<Select value={currentValue} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{terminals.map((terminal) => (
|
||||
<SelectItem key={terminal.value} value={terminal.value}>
|
||||
{t(terminal.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.fallbackHint")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
import { AppWindow, MonitorUp, Power } from "lucide-react";
|
||||
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
|
||||
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||
|
||||
interface WindowSettingsProps {
|
||||
@@ -27,6 +27,14 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
||||
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<EyeOff className="h-4 w-4 text-green-500" />}
|
||||
title={t("settings.silentStartup")}
|
||||
description={t("settings.silentStartupDescription")}
|
||||
checked={!!settings.silentStartup}
|
||||
onCheckedChange={(value) => onChange({ silentStartup: value })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
|
||||
title={t("settings.enableClaudePluginIntegration")}
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
useUninstallSkill,
|
||||
useScanUnmanagedSkills,
|
||||
useImportSkillsFromApps,
|
||||
useInstallSkillsFromZip,
|
||||
type InstalledSkill,
|
||||
type AppType,
|
||||
} from "@/hooks/useSkills";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { settingsApi, skillsApi } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UnifiedSkillsPanelProps {
|
||||
@@ -27,6 +28,7 @@ interface UnifiedSkillsPanelProps {
|
||||
export interface UnifiedSkillsPanelHandle {
|
||||
openDiscovery: () => void;
|
||||
openImport: () => void;
|
||||
openInstallFromZip: () => void;
|
||||
}
|
||||
|
||||
const UnifiedSkillsPanel = React.forwardRef<
|
||||
@@ -49,6 +51,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const { data: unmanagedSkills, refetch: scanUnmanaged } =
|
||||
useScanUnmanagedSkills();
|
||||
const importMutation = useImportSkillsFromApps();
|
||||
const installFromZipMutation = useInstallSkillsFromZip();
|
||||
|
||||
// Count enabled skills per app
|
||||
const enabledCounts = useMemo(() => {
|
||||
@@ -127,9 +130,52 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallFromZip = async () => {
|
||||
try {
|
||||
// 打开文件选择对话框
|
||||
const filePath = await skillsApi.openZipFileDialog();
|
||||
if (!filePath) {
|
||||
// 用户取消选择
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认使用 claude 作为当前应用
|
||||
const currentApp: AppType = "claude";
|
||||
|
||||
// 安装 Skills
|
||||
const installed = await installFromZipMutation.mutateAsync({
|
||||
filePath,
|
||||
currentApp,
|
||||
});
|
||||
|
||||
if (installed.length === 0) {
|
||||
toast.info(t("skills.installFromZip.noSkillsFound"), {
|
||||
closeButton: true,
|
||||
});
|
||||
} else if (installed.length === 1) {
|
||||
toast.success(
|
||||
t("skills.installFromZip.successSingle", { name: installed[0].name }),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t("skills.installFromZip.successMultiple", {
|
||||
count: installed.length,
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("skills.installFailed"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openDiscovery: onOpenDiscovery,
|
||||
openImport: handleOpenImport,
|
||||
openInstallFromZip: handleInstallFromZip,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,10 +18,31 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
|
||||
import { PricingEditModal } from "./PricingEditModal";
|
||||
import type { ModelPricing } from "@/types/usage";
|
||||
import { Plus, Pencil, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
|
||||
const PRICING_APPS = ["claude", "codex", "gemini"] as const;
|
||||
type PricingApp = (typeof PRICING_APPS)[number];
|
||||
type PricingModelSource = "request" | "response";
|
||||
|
||||
interface AppConfig {
|
||||
multiplier: string;
|
||||
source: PricingModelSource;
|
||||
}
|
||||
|
||||
type AppConfigState = Record<PricingApp, AppConfig>;
|
||||
|
||||
export function PricingConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,13 +51,137 @@ export function PricingConfigPanel() {
|
||||
const [editingModel, setEditingModel] = useState<ModelPricing | null>(null);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 三个应用的配置状态
|
||||
const [appConfigs, setAppConfigs] = useState<AppConfigState>({
|
||||
claude: { multiplier: "1", source: "response" },
|
||||
codex: { multiplier: "1", source: "response" },
|
||||
gemini: { multiplier: "1", source: "response" },
|
||||
});
|
||||
const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(
|
||||
null,
|
||||
);
|
||||
const [isConfigLoading, setIsConfigLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 检查是否有改动
|
||||
const isDirty =
|
||||
originalConfigs !== null &&
|
||||
PRICING_APPS.some(
|
||||
(app) =>
|
||||
appConfigs[app].multiplier !== originalConfigs[app].multiplier ||
|
||||
appConfigs[app].source !== originalConfigs[app].source,
|
||||
);
|
||||
|
||||
// 加载所有应用的配置
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadAllConfigs = async () => {
|
||||
setIsConfigLoading(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
PRICING_APPS.map(async (app) => {
|
||||
const [multiplier, source] = await Promise.all([
|
||||
proxyApi.getDefaultCostMultiplier(app),
|
||||
proxyApi.getPricingModelSource(app),
|
||||
]);
|
||||
return {
|
||||
app,
|
||||
multiplier,
|
||||
source: (source === "request"
|
||||
? "request"
|
||||
: "response") as PricingModelSource,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
const newState: AppConfigState = {
|
||||
claude: { multiplier: "1", source: "response" },
|
||||
codex: { multiplier: "1", source: "response" },
|
||||
gemini: { multiplier: "1", source: "response" },
|
||||
};
|
||||
for (const result of results) {
|
||||
newState[result.app] = {
|
||||
multiplier: result.multiplier,
|
||||
source: result.source,
|
||||
};
|
||||
}
|
||||
setAppConfigs(newState);
|
||||
setOriginalConfigs(newState);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "Unknown error";
|
||||
toast.error(
|
||||
t("settings.globalProxy.pricingLoadFailed", { error: message }),
|
||||
);
|
||||
} finally {
|
||||
if (isMounted) setIsConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllConfigs();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// 保存所有配置
|
||||
const handleSaveAll = async () => {
|
||||
// 验证所有倍率
|
||||
for (const app of PRICING_APPS) {
|
||||
const trimmed = appConfigs[app].multiplier.trim();
|
||||
if (!trimmed) {
|
||||
toast.error(
|
||||
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierRequired")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
|
||||
toast.error(
|
||||
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierInvalid")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
PRICING_APPS.flatMap((app) => [
|
||||
proxyApi.setDefaultCostMultiplier(
|
||||
app,
|
||||
appConfigs[app].multiplier.trim(),
|
||||
),
|
||||
proxyApi.setPricingModelSource(app, appConfigs[app].source),
|
||||
]),
|
||||
);
|
||||
toast.success(t("settings.globalProxy.pricingSaved"));
|
||||
setOriginalConfigs({ ...appConfigs });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "Unknown error";
|
||||
toast.error(
|
||||
t("settings.globalProxy.pricingSaveFailed", { error: message }),
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (modelId: string) => {
|
||||
deleteMutation.mutate(modelId, {
|
||||
onSuccess: () => {
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onSuccess: () => setDeleteConfirm(null),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -55,151 +199,242 @@ export function PricingConfigPanel() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border rounded-lg">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border rounded-lg">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.loadPricingError")}: {String(error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.loadPricingError")}: {String(error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||
</h4>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddNew();
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* 全局计费默认配置 - 紧凑表格布局 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("settings.globalProxy.pricingDefaultsTitle")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.globalProxy.pricingDefaultsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
disabled={isConfigLoading || isSaving || !isDirty}
|
||||
size="sm"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{t("common.saving")}
|
||||
</>
|
||||
) : (
|
||||
t("common.save")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!pricing || pricing.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
|
||||
</Alert>
|
||||
{isConfigLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("usage.model")}</TableHead>
|
||||
<TableHead>{t("usage.displayName")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.inputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.outputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheReadCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheWriteCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pricing.map((model) => (
|
||||
<TableRow key={model.modelId}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{model.modelId}
|
||||
</TableCell>
|
||||
<TableCell>{model.displayName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.inputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.outputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheReadCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheCreationCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsAddingNew(false);
|
||||
setEditingModel(model);
|
||||
}}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteConfirm(model.modelId)}
|
||||
title={t("common.delete")}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-muted/30">
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground w-24">
|
||||
{t("settings.globalProxy.pricingAppLabel")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
{t("settings.globalProxy.defaultCostMultiplierLabel")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
{t("settings.globalProxy.pricingModelSourceLabel")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PRICING_APPS.map((app, idx) => (
|
||||
<tr
|
||||
key={app}
|
||||
className={
|
||||
idx < PRICING_APPS.length - 1
|
||||
? "border-b border-border/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-1.5 font-medium">
|
||||
{t(`apps.${app}`)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={appConfigs[app].multiplier}
|
||||
onChange={(e) =>
|
||||
setAppConfigs((prev) => ({
|
||||
...prev,
|
||||
[app]: { ...prev[app], multiplier: e.target.value },
|
||||
}))
|
||||
}
|
||||
disabled={isSaving}
|
||||
placeholder="1"
|
||||
className="h-7 w-24"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<Select
|
||||
value={appConfigs[app].source}
|
||||
onValueChange={(value) =>
|
||||
setAppConfigs((prev) => ({
|
||||
...prev,
|
||||
[app]: {
|
||||
...prev[app],
|
||||
source: value as PricingModelSource,
|
||||
},
|
||||
}))
|
||||
}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="response">
|
||||
{t(
|
||||
"settings.globalProxy.pricingModelSourceResponse",
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="request">
|
||||
{t(
|
||||
"settings.globalProxy.pricingModelSourceRequest",
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="border-t border-border/50" />
|
||||
|
||||
{/* 模型定价配置 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||
</h4>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddNew();
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!pricing || pricing.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("usage.model")}</TableHead>
|
||||
<TableHead>{t("usage.displayName")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.inputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.outputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheReadCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheWriteCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pricing.map((model) => (
|
||||
<TableRow key={model.modelId}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{model.modelId}
|
||||
</TableCell>
|
||||
<TableCell>{model.displayName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.inputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.outputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheReadCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheCreationCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsAddingNew(false);
|
||||
setEditingModel(model);
|
||||
}}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteConfirm(model.modelId)}
|
||||
title={t("common.delete")}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingModel && (
|
||||
<PricingEditModal
|
||||
open={!!editingModel}
|
||||
|
||||
@@ -184,6 +184,9 @@ export function RequestDetailPanel({
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.inputCost", "输入成本")}
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.baseCost", "基础")})
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.inputCostUsd).toFixed(6)}
|
||||
@@ -192,6 +195,9 @@ export function RequestDetailPanel({
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.outputCost", "输出成本")}
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.baseCost", "基础")})
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.outputCostUsd).toFixed(6)}
|
||||
@@ -200,6 +206,9 @@ export function RequestDetailPanel({
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.cacheReadCost", "缓存读取成本")}
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.baseCost", "基础")})
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.cacheReadCostUsd).toFixed(6)}
|
||||
@@ -208,14 +217,35 @@ export function RequestDetailPanel({
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.cacheCreationCost", "缓存写入成本")}
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.baseCost", "基础")})
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2 border-t pt-3">
|
||||
{/* 显示成本倍率(如果不等于1) */}
|
||||
{request.costMultiplier &&
|
||||
parseFloat(request.costMultiplier) !== 1 && (
|
||||
<div className="col-span-2 border-t pt-3">
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.costMultiplier", "成本倍率")}
|
||||
</dt>
|
||||
<dd className="font-mono">×{request.costMultiplier}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`col-span-2 ${request.costMultiplier && parseFloat(request.costMultiplier) !== 1 ? "" : "border-t"} pt-3`}
|
||||
>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.totalCost", "总成本")}
|
||||
{request.costMultiplier &&
|
||||
parseFloat(request.costMultiplier) !== 1 && (
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.withMultiplier", "含倍率")})
|
||||
</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-primary">
|
||||
${parseFloat(request.totalCostUsd).toFixed(6)}
|
||||
|
||||
@@ -250,7 +250,7 @@ export function RequestLogTable() {
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("usage.provider")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[280px] whitespace-nowrap">
|
||||
<TableHead className="min-w-[200px] whitespace-nowrap">
|
||||
{t("usage.billingModel")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
@@ -265,6 +265,9 @@ export function RequestLogTable() {
|
||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||
{t("usage.cacheCreationTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.multiplier")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.totalCost")}
|
||||
</TableHead>
|
||||
@@ -280,7 +283,7 @@ export function RequestLogTable() {
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={10}
|
||||
colSpan={11}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("usage.noData")}
|
||||
@@ -297,11 +300,25 @@ export function RequestLogTable() {
|
||||
<TableCell>
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="font-mono text-sm max-w-[280px] truncate"
|
||||
title={log.model}
|
||||
>
|
||||
{log.model}
|
||||
<TableCell className="font-mono text-xs max-w-[200px]">
|
||||
<div
|
||||
className="truncate"
|
||||
title={
|
||||
log.requestModel && log.requestModel !== log.model
|
||||
? `${t("usage.requestModel")}: ${log.requestModel}\n${t("usage.responseModel")}: ${log.model}`
|
||||
: log.model
|
||||
}
|
||||
>
|
||||
{log.model}
|
||||
</div>
|
||||
{log.requestModel && log.requestModel !== log.model && (
|
||||
<div
|
||||
className="truncate text-muted-foreground text-[10px]"
|
||||
title={log.requestModel}
|
||||
>
|
||||
← {log.requestModel}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.inputTokens.toLocaleString()}
|
||||
@@ -315,6 +332,15 @@ export function RequestLogTable() {
|
||||
<TableCell className="text-right">
|
||||
{log.cacheCreationTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{parseFloat(log.costMultiplier) !== 1 ? (
|
||||
<span className="text-orange-600">
|
||||
×{log.costMultiplier}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">×1</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
||||
</TableCell>
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface ProviderPreset {
|
||||
// 图标配置
|
||||
icon?: string; // 图标名称
|
||||
iconColor?: string; // 图标颜色
|
||||
|
||||
// Claude API 格式(仅 Claude 供应商使用)
|
||||
// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
apiFormat?: "anthropic" | "openai_chat";
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
@@ -143,10 +148,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "kimi-k2-thinking",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2-thinking",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2-thinking",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2-thinking",
|
||||
ANTHROPIC_MODEL: "kimi-k2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2.5",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -160,10 +165,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.kimi.com/coding/",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "kimi-for-coding",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-for-coding",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-for-coding",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-for-coding",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -465,6 +466,26 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "rc",
|
||||
iconColor: "#E96B2C",
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.aicodemirror.com/api/claudecode",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
endpointCandidates: [
|
||||
"https://api.aicodemirror.com/api/claudecode",
|
||||
"https://api.claudecode.net.cn/api/claudecode",
|
||||
],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "aicodemirror", // 促销信息 i18n key
|
||||
icon: "aicodemirror",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
@@ -483,6 +504,25 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "openrouter",
|
||||
iconColor: "#6566F1",
|
||||
},
|
||||
{
|
||||
name: "Nvidia",
|
||||
websiteUrl: "https://build.nvidia.com",
|
||||
apiKeyUrl: "https://build.nvidia.com/settings/api-keys",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://integrate.api.nvidia.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "moonshotai/kimi-k2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "moonshotai/kimi-k2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "moonshotai/kimi-k2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "moonshotai/kimi-k2.5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
apiFormat: "openai_chat",
|
||||
icon: "nvidia",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "Xiaomi MiMo",
|
||||
websiteUrl: "https://platform.xiaomimimo.com",
|
||||
|
||||
@@ -208,6 +208,25 @@ requires_openai_auth = true`,
|
||||
icon: "rc",
|
||||
iconColor: "#E96B2C",
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"aicodemirror",
|
||||
"https://api.aicodemirror.com/api/codex/backend-api/codex",
|
||||
"gpt-5.2",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://api.aicodemirror.com/api/codex/backend-api/codex",
|
||||
"https://api.claudecode.net.cn/api/codex/backend-api/codex",
|
||||
],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicodemirror",
|
||||
icon: "aicodemirror",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
|
||||
@@ -116,6 +116,29 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.aicodemirror.com/api/gemini",
|
||||
GEMINI_MODEL: "gemini-3-pro",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.aicodemirror.com/api/gemini",
|
||||
model: "gemini-3-pro",
|
||||
description: "AICodeMirror",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicodemirror",
|
||||
endpointCandidates: [
|
||||
"https://api.aicodemirror.com/api/gemini",
|
||||
"https://api.claudecode.net.cn/api/gemini",
|
||||
],
|
||||
icon: "aicodemirror",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
|
||||
@@ -169,18 +169,18 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi k2",
|
||||
name: "Kimi k2.5",
|
||||
websiteUrl: "https://platform.moonshot.cn/console",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Kimi k2",
|
||||
name: "Kimi k2.5",
|
||||
options: {
|
||||
baseURL: "https://api.moonshot.cn/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"kimi-k2-thinking": { name: "Kimi K2 Thinking" },
|
||||
"kimi-k2.5": { name: "Kimi K2.5" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -558,6 +558,32 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Nvidia",
|
||||
websiteUrl: "https://build.nvidia.com",
|
||||
apiKeyUrl: "https://build.nvidia.com/settings/api-keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Nvidia",
|
||||
options: {
|
||||
baseURL: "https://integrate.api.nvidia.com/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"moonshotai/kimi-k2.5": { name: "Kimi K2.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "nvidia",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ========== 第三方合作伙伴 ==========
|
||||
{
|
||||
@@ -675,6 +701,35 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "AICodeMirror",
|
||||
options: {
|
||||
baseURL: "https://api.aicodemirror.com/api/claudecode",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4.5": { name: "Claude Opus 4.5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicodemirror",
|
||||
icon: "aicodemirror",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ========== 自定义模板 ==========
|
||||
{
|
||||
|
||||
@@ -5,13 +5,14 @@ import { homeDir, join } from "@tauri-apps/api/path";
|
||||
import { settingsApi, type AppId } from "@/lib/api";
|
||||
import type { SettingsFormState } from "./useSettingsForm";
|
||||
|
||||
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini";
|
||||
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini" | "opencode";
|
||||
|
||||
export interface ResolvedDirectories {
|
||||
appConfig: string;
|
||||
claude: string;
|
||||
codex: string;
|
||||
gemini: string;
|
||||
opencode: string;
|
||||
}
|
||||
|
||||
const sanitizeDir = (value?: string | null): string | undefined => {
|
||||
@@ -39,7 +40,13 @@ const computeDefaultConfigDir = async (
|
||||
try {
|
||||
const home = await homeDir();
|
||||
const folder =
|
||||
app === "claude" ? ".claude" : app === "codex" ? ".codex" : ".gemini";
|
||||
app === "claude"
|
||||
? ".claude"
|
||||
: app === "codex"
|
||||
? ".codex"
|
||||
: app === "gemini"
|
||||
? ".gemini"
|
||||
: ".config/opencode";
|
||||
return await join(home, folder);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -70,6 +77,7 @@ export interface UseDirectorySettingsResult {
|
||||
claudeDir?: string,
|
||||
codexDir?: string,
|
||||
geminiDir?: string,
|
||||
opencodeDir?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -96,6 +104,7 @@ export function useDirectorySettings({
|
||||
claude: "",
|
||||
codex: "",
|
||||
gemini: "",
|
||||
opencode: "",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -104,6 +113,7 @@ export function useDirectorySettings({
|
||||
claude: "",
|
||||
codex: "",
|
||||
gemini: "",
|
||||
opencode: "",
|
||||
});
|
||||
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
|
||||
|
||||
@@ -119,19 +129,23 @@ export function useDirectorySettings({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
geminiDir,
|
||||
opencodeDir,
|
||||
defaultAppConfig,
|
||||
defaultClaudeDir,
|
||||
defaultCodexDir,
|
||||
defaultGeminiDir,
|
||||
defaultOpencodeDir,
|
||||
] = await Promise.all([
|
||||
settingsApi.getAppConfigDirOverride(),
|
||||
settingsApi.getConfigDir("claude"),
|
||||
settingsApi.getConfigDir("codex"),
|
||||
settingsApi.getConfigDir("gemini"),
|
||||
settingsApi.getConfigDir("opencode"),
|
||||
computeDefaultAppConfigDir(),
|
||||
computeDefaultConfigDir("claude"),
|
||||
computeDefaultConfigDir("codex"),
|
||||
computeDefaultConfigDir("gemini"),
|
||||
computeDefaultConfigDir("opencode"),
|
||||
]);
|
||||
|
||||
if (!active) return;
|
||||
@@ -143,6 +157,7 @@ export function useDirectorySettings({
|
||||
claude: defaultClaudeDir ?? "",
|
||||
codex: defaultCodexDir ?? "",
|
||||
gemini: defaultGeminiDir ?? "",
|
||||
opencode: defaultOpencodeDir ?? "",
|
||||
};
|
||||
|
||||
setAppConfigDir(normalizedOverride);
|
||||
@@ -153,6 +168,7 @@ export function useDirectorySettings({
|
||||
claude: claudeDir || defaultsRef.current.claude,
|
||||
codex: codexDir || defaultsRef.current.codex,
|
||||
gemini: geminiDir || defaultsRef.current.gemini,
|
||||
opencode: opencodeDir || defaultsRef.current.opencode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -183,7 +199,9 @@ export function useDirectorySettings({
|
||||
? { claudeConfigDir: sanitized }
|
||||
: key === "codex"
|
||||
? { codexConfigDir: sanitized }
|
||||
: { geminiConfigDir: sanitized },
|
||||
: key === "gemini"
|
||||
? { geminiConfigDir: sanitized }
|
||||
: { opencodeConfigDir: sanitized },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,7 +223,13 @@ export function useDirectorySettings({
|
||||
const updateDirectory = useCallback(
|
||||
(app: AppId, value?: string) => {
|
||||
updateDirectoryState(
|
||||
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini",
|
||||
app === "claude"
|
||||
? "claude"
|
||||
: app === "codex"
|
||||
? "codex"
|
||||
: app === "gemini"
|
||||
? "gemini"
|
||||
: "opencode",
|
||||
value,
|
||||
);
|
||||
},
|
||||
@@ -215,13 +239,21 @@ export function useDirectorySettings({
|
||||
const browseDirectory = useCallback(
|
||||
async (app: AppId) => {
|
||||
const key: DirectoryKey =
|
||||
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
|
||||
app === "claude"
|
||||
? "claude"
|
||||
: app === "codex"
|
||||
? "codex"
|
||||
: app === "gemini"
|
||||
? "gemini"
|
||||
: "opencode";
|
||||
const currentValue =
|
||||
key === "claude"
|
||||
? (settings?.claudeConfigDir ?? resolvedDirs.claude)
|
||||
: key === "codex"
|
||||
? (settings?.codexConfigDir ?? resolvedDirs.codex)
|
||||
: (settings?.geminiConfigDir ?? resolvedDirs.gemini);
|
||||
: key === "gemini"
|
||||
? (settings?.geminiConfigDir ?? resolvedDirs.gemini)
|
||||
: (settings?.opencodeConfigDir ?? resolvedDirs.opencode);
|
||||
|
||||
try {
|
||||
const picked = await settingsApi.selectConfigDirectory(currentValue);
|
||||
@@ -263,7 +295,13 @@ export function useDirectorySettings({
|
||||
const resetDirectory = useCallback(
|
||||
async (app: AppId) => {
|
||||
const key: DirectoryKey =
|
||||
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
|
||||
app === "claude"
|
||||
? "claude"
|
||||
: app === "codex"
|
||||
? "codex"
|
||||
: app === "gemini"
|
||||
? "gemini"
|
||||
: "opencode";
|
||||
if (!defaultsRef.current[key]) {
|
||||
const fallback = await computeDefaultConfigDir(app);
|
||||
if (fallback) {
|
||||
@@ -292,7 +330,12 @@ export function useDirectorySettings({
|
||||
}, [updateDirectoryState]);
|
||||
|
||||
const resetAllDirectories = useCallback(
|
||||
(claudeDir?: string, codexDir?: string, geminiDir?: string) => {
|
||||
(
|
||||
claudeDir?: string,
|
||||
codexDir?: string,
|
||||
geminiDir?: string,
|
||||
opencodeDir?: string,
|
||||
) => {
|
||||
setAppConfigDir(initialAppConfigDirRef.current);
|
||||
setResolvedDirs({
|
||||
appConfig:
|
||||
@@ -300,6 +343,7 @@ export function useDirectorySettings({
|
||||
claude: claudeDir ?? defaultsRef.current.claude,
|
||||
codex: codexDir ?? defaultsRef.current.codex,
|
||||
gemini: geminiDir ?? defaultsRef.current.gemini,
|
||||
opencode: opencodeDir ?? defaultsRef.current.opencode,
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -84,11 +84,43 @@ export function useProviderActions(activeApp: AppId) {
|
||||
try {
|
||||
await switchProviderMutation.mutateAsync(provider.id);
|
||||
await syncClaudePlugin(provider);
|
||||
|
||||
// 根据供应商类型显示不同的成功提示
|
||||
if (
|
||||
activeApp === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
provider.meta?.apiFormat === "openai_chat"
|
||||
) {
|
||||
// OpenAI Chat 格式供应商:显示代理提示
|
||||
toast.info(
|
||||
t("notifications.openAIChatFormatHint", {
|
||||
defaultValue:
|
||||
"此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
|
||||
}),
|
||||
{
|
||||
duration: 5000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 普通供应商:显示切换成功
|
||||
// OpenCode: show "added to config" message instead of "switched"
|
||||
const messageKey =
|
||||
activeApp === "opencode"
|
||||
? "notifications.addToConfigSuccess"
|
||||
: "notifications.switchSuccess";
|
||||
const defaultMessage =
|
||||
activeApp === "opencode" ? "已添加到配置" : "切换成功!";
|
||||
|
||||
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 错误提示由 mutation 与同步函数处理
|
||||
// 错误提示由 mutation 处理
|
||||
}
|
||||
},
|
||||
[switchProviderMutation, syncClaudePlugin],
|
||||
[switchProviderMutation, syncClaudePlugin, activeApp, t],
|
||||
);
|
||||
|
||||
// 删除供应商
|
||||
|
||||
@@ -109,6 +109,7 @@ export function useSettings(): UseSettingsResult {
|
||||
sanitizeDir(data?.claudeConfigDir),
|
||||
sanitizeDir(data?.codexConfigDir),
|
||||
sanitizeDir(data?.geminiConfigDir),
|
||||
sanitizeDir(data?.opencodeConfigDir),
|
||||
);
|
||||
setRequiresRestart(false);
|
||||
}, [
|
||||
@@ -131,12 +132,16 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
opencodeConfigDir: sanitizedOpencodeDir,
|
||||
language: mergedSettings.language,
|
||||
};
|
||||
|
||||
@@ -238,16 +243,21 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
const previousAppDir = initialAppConfigDir;
|
||||
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
|
||||
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
|
||||
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
opencodeConfigDir: sanitizedOpencodeDir,
|
||||
language: mergedSettings.language,
|
||||
};
|
||||
|
||||
@@ -344,11 +354,17 @@ export function useSettings(): UseSettingsResult {
|
||||
console.warn("[useSettings] Failed to refresh tray menu", error);
|
||||
}
|
||||
|
||||
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
|
||||
// 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置
|
||||
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
|
||||
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
|
||||
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
|
||||
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
|
||||
const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir;
|
||||
if (
|
||||
claudeDirChanged ||
|
||||
codexDirChanged ||
|
||||
geminiDirChanged ||
|
||||
opencodeDirChanged
|
||||
) {
|
||||
const syncResult = await syncCurrentProvidersLiveSafe();
|
||||
if (!syncResult.ok) {
|
||||
console.warn(
|
||||
|
||||
@@ -83,9 +83,12 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
|
||||
enableClaudePluginIntegration:
|
||||
data.enableClaudePluginIntegration ?? false,
|
||||
silentStartup: data.silentStartup ?? false,
|
||||
skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,
|
||||
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(data.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
|
||||
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
|
||||
language: normalizedLanguage,
|
||||
};
|
||||
|
||||
@@ -138,9 +141,12 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
|
||||
enableClaudePluginIntegration:
|
||||
serverData.enableClaudePluginIntegration ?? false,
|
||||
silentStartup: serverData.silentStartup ?? false,
|
||||
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,
|
||||
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
|
||||
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
|
||||
language: normalizedLanguage,
|
||||
};
|
||||
|
||||
|
||||
@@ -147,6 +147,26 @@ export function useRemoveSkillRepo() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ZIP 文件安装 Skills
|
||||
*/
|
||||
export function useInstallSkillsFromZip() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
filePath,
|
||||
currentApp,
|
||||
}: {
|
||||
filePath: string;
|
||||
currentApp: AppType;
|
||||
}) => skillsApi.installFromZip(filePath, currentApp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 辅助类型 ==========
|
||||
|
||||
export type { InstalledSkill, DiscoverableSkill, AppType };
|
||||
|
||||
@@ -155,7 +155,8 @@
|
||||
"deleteSuccess": "Provider deleted",
|
||||
"deleteFailed": "Failed to delete provider: {{error}}",
|
||||
"settingsSaved": "Settings saved",
|
||||
"settingsSaveFailed": "Failed to save settings: {{error}}"
|
||||
"settingsSaveFailed": "Failed to save settings: {{error}}",
|
||||
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
@@ -265,6 +266,8 @@
|
||||
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
|
||||
"launchOnStartup": "Launch on Startup",
|
||||
"launchOnStartupDescription": "Automatically run CC Switch when system starts",
|
||||
"silentStartup": "Silent Startup",
|
||||
"silentStartupDescription": "Start in background mode without showing main window",
|
||||
"autoLaunchFailed": "Failed to set auto-launch",
|
||||
"minimizeToTray": "Minimize to tray on close",
|
||||
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
||||
@@ -287,6 +290,33 @@
|
||||
"copy": "Copy Files",
|
||||
"symlinkHint": "Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Preferred Terminal",
|
||||
"description": "Choose which terminal app to use when clicking the terminal button",
|
||||
"fallbackHint": "If the selected terminal is unavailable, the system default will be used",
|
||||
"options": {
|
||||
"macos": {
|
||||
"terminal": "Terminal.app",
|
||||
"iterm2": "iTerm2",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
},
|
||||
"windows": {
|
||||
"cmd": "Command Prompt",
|
||||
"powershell": "PowerShell",
|
||||
"wt": "Windows Terminal"
|
||||
},
|
||||
"linux": {
|
||||
"gnomeTerminal": "GNOME Terminal",
|
||||
"konsole": "Konsole",
|
||||
"xfce4Terminal": "Xfce4 Terminal",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
||||
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
|
||||
"appConfigDir": "CC Switch Configuration Directory",
|
||||
@@ -298,9 +328,12 @@
|
||||
"codexConfigDirDescription": "Override Codex configuration directory.",
|
||||
"geminiConfigDir": "Gemini Configuration Directory",
|
||||
"geminiConfigDirDescription": "Override Gemini configuration directory (.env).",
|
||||
"opencodeConfigDir": "OpenCode Configuration Directory",
|
||||
"opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).",
|
||||
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
|
||||
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
|
||||
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
|
||||
"browsePlaceholderOpencode": "e.g., /home/<your-username>/.config/opencode",
|
||||
"browseDirectory": "Browse Directory",
|
||||
"resetDefault": "Reset to default directory (takes effect after saving)",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
@@ -319,7 +352,7 @@
|
||||
"viewReleaseNotes": "View release notes for this version",
|
||||
"viewCurrentReleaseNotes": "View current version release notes",
|
||||
"oneClickInstall": "One-click Install",
|
||||
"oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI",
|
||||
"oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI / OpenCode",
|
||||
"localEnvCheck": "Local environment check",
|
||||
"installCommandsCopied": "Install commands copied",
|
||||
"installCommandsCopyFailed": "Copy failed, please copy manually.",
|
||||
@@ -344,7 +377,21 @@
|
||||
"saved": "Proxy settings saved",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"testSuccess": "Connected! Latency {{latency}}ms",
|
||||
"testFailed": "Connection failed: {{error}}"
|
||||
"testFailed": "Connection failed: {{error}}",
|
||||
"pricingDefaultsTitle": "Pricing Defaults",
|
||||
"pricingDefaultsDescription": "Set the default multiplier and pricing model source per app.",
|
||||
"pricingAppLabel": "App",
|
||||
"defaultCostMultiplierLabel": "Default Multiplier",
|
||||
"defaultCostMultiplierHint": "Multiplier for cost calculation, decimals supported.",
|
||||
"pricingModelSourceLabel": "Pricing Model Source",
|
||||
"pricingModelSourceRequest": "Request model",
|
||||
"pricingModelSourceResponse": "Response model",
|
||||
"pricingSave": "Save Pricing Defaults",
|
||||
"pricingSaved": "Pricing defaults saved",
|
||||
"pricingSaveFailed": "Failed to save pricing defaults: {{error}}",
|
||||
"pricingLoadFailed": "Failed to load pricing defaults: {{error}}",
|
||||
"defaultCostMultiplierRequired": "Default multiplier is required",
|
||||
"defaultCostMultiplierInvalid": "Invalid multiplier format"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
@@ -401,7 +448,8 @@
|
||||
"dmxapi": "Claude Code exclusive model 66% OFF now!",
|
||||
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up",
|
||||
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!",
|
||||
"rightcode": "RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!"
|
||||
"rightcode": "RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!",
|
||||
"aicodemirror": "AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!"
|
||||
},
|
||||
"parameterConfig": "Parameter Config - {{name}} *",
|
||||
"mainModel": "Main Model (optional)",
|
||||
@@ -426,8 +474,10 @@
|
||||
"anthropicModel": "Main Model",
|
||||
"anthropicSmallFastModel": "Fast Model",
|
||||
"anthropicReasoningModel": "Reasoning Model (Thinking)",
|
||||
"openrouterCompatMode": "OpenRouter Compatibility Mode",
|
||||
"openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.",
|
||||
"apiFormat": "API Format",
|
||||
"apiFormatHint": "Select the input format for the provider's API",
|
||||
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
|
||||
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
||||
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
||||
"anthropicDefaultOpusModel": "Default Opus Model",
|
||||
@@ -480,7 +530,18 @@
|
||||
"useCustomProxy": "Use separate proxy",
|
||||
"proxyConfigDesc": "Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.",
|
||||
"proxyUsername": "Username (optional)",
|
||||
"proxyPassword": "Password (optional)"
|
||||
"proxyPassword": "Password (optional)",
|
||||
"pricingConfig": "Pricing Config",
|
||||
"useCustomPricing": "Use separate config",
|
||||
"pricingConfigDesc": "Configure separate pricing parameters for this provider. Uses global defaults when disabled.",
|
||||
"costMultiplier": "Cost Multiplier",
|
||||
"costMultiplierPlaceholder": "Leave empty to use global default (1)",
|
||||
"costMultiplierHint": "Actual cost = Base cost × Multiplier, supports decimals like 1.5",
|
||||
"pricingModelSourceLabel": "Pricing Mode",
|
||||
"pricingModelSourceInherit": "Inherit global default",
|
||||
"pricingModelSourceRequest": "Request model",
|
||||
"pricingModelSourceResponse": "Response model",
|
||||
"pricingModelSourceHint": "Choose whether to match pricing by request model or response model"
|
||||
},
|
||||
"codexConfig": {
|
||||
"authJson": "auth.json (JSON) *",
|
||||
@@ -583,6 +644,9 @@
|
||||
"cacheCreationTokens": "Cache Creation",
|
||||
"timingInfo": "Duration/TTFT",
|
||||
"status": "Status",
|
||||
"multiplier": "Multiplier",
|
||||
"requestModel": "Request Model",
|
||||
"responseModel": "Response Model",
|
||||
"noData": "No data",
|
||||
"unknownProvider": "Unknown Provider",
|
||||
"stream": "Stream",
|
||||
@@ -625,7 +689,19 @@
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Creation",
|
||||
"cacheRead": "Hit"
|
||||
"cacheRead": "Hit",
|
||||
"baseCost": "Base",
|
||||
"costMultiplier": "Cost Multiplier",
|
||||
"withMultiplier": "with multiplier",
|
||||
"requestDetail": "Request Detail",
|
||||
"requestNotFound": "Request not found",
|
||||
"basicInfo": "Basic Info",
|
||||
"tokenUsage": "Token Usage",
|
||||
"cacheCreationCost": "Cache Creation Cost",
|
||||
"costBreakdown": "Cost Breakdown",
|
||||
"performance": "Performance",
|
||||
"latency": "Latency",
|
||||
"errorMessage": "Error Message"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "Configure Usage Query",
|
||||
@@ -982,6 +1058,7 @@
|
||||
"http429": "Too many requests, please wait and retry",
|
||||
"parseMetadataFailed": "Failed to parse skill metadata",
|
||||
"getHomeDirFailed": "Unable to get user home directory",
|
||||
"noSkillsInZip": "No skills found in ZIP file (requires SKILL.md file)",
|
||||
"networkError": "Network error",
|
||||
"fsError": "File system error",
|
||||
"unknownError": "Unknown error",
|
||||
@@ -992,7 +1069,8 @@
|
||||
"checkRepoUrl": "Please check repository URL and branch name",
|
||||
"checkDiskSpace": "Please check disk space",
|
||||
"checkPermission": "Please check directory permissions",
|
||||
"uninstallFirst": "Please uninstall the existing skill with the same name first"
|
||||
"uninstallFirst": "Please uninstall the existing skill with the same name first",
|
||||
"checkZipContent": "Please verify the ZIP file contains valid skill directories (with SKILL.md files)"
|
||||
}
|
||||
},
|
||||
"repo": {
|
||||
@@ -1041,6 +1119,13 @@
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"installFromZip": {
|
||||
"button": "Install from ZIP",
|
||||
"installing": "Installing...",
|
||||
"successSingle": "Skill {{name}} installed",
|
||||
"successMultiple": "Successfully installed {{count}} skills",
|
||||
"noSkillsFound": "No skills found in ZIP file (requires SKILL.md file)"
|
||||
}
|
||||
},
|
||||
"deeplink": {
|
||||
|
||||
@@ -155,7 +155,8 @@
|
||||
"deleteSuccess": "プロバイダーを削除しました",
|
||||
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
|
||||
"settingsSaved": "設定を保存しました",
|
||||
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}"
|
||||
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
|
||||
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
@@ -265,6 +266,8 @@
|
||||
"windowBehaviorHint": "最小化動作や Claude プラグイン連携を設定します。",
|
||||
"launchOnStartup": "起動時に自動実行",
|
||||
"launchOnStartupDescription": "システム起動時に CC Switch を自動起動します",
|
||||
"silentStartup": "サイレント起動",
|
||||
"silentStartupDescription": "起動時にメインウィンドウを表示せず、トレイのみで起動",
|
||||
"autoLaunchFailed": "自動起動の設定に失敗しました",
|
||||
"minimizeToTray": "閉じるときトレイへ最小化",
|
||||
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
|
||||
@@ -287,6 +290,33 @@
|
||||
"copy": "ファイルコピー",
|
||||
"symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "優先ターミナル",
|
||||
"description": "ターミナルボタンをクリックした時に使用するターミナルアプリを選択",
|
||||
"fallbackHint": "選択したターミナルが利用できない場合、システムのデフォルトが使用されます",
|
||||
"options": {
|
||||
"macos": {
|
||||
"terminal": "Terminal.app",
|
||||
"iterm2": "iTerm2",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
},
|
||||
"windows": {
|
||||
"cmd": "コマンドプロンプト",
|
||||
"powershell": "PowerShell",
|
||||
"wt": "Windows Terminal"
|
||||
},
|
||||
"linux": {
|
||||
"gnomeTerminal": "GNOME Terminal",
|
||||
"konsole": "Konsole",
|
||||
"xfce4Terminal": "Xfce4 Terminal",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
|
||||
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
|
||||
"appConfigDir": "CC Switch 設定ディレクトリ",
|
||||
@@ -298,9 +328,12 @@
|
||||
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
|
||||
"geminiConfigDir": "Gemini 設定ディレクトリ",
|
||||
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
|
||||
"opencodeConfigDir": "OpenCode 設定ディレクトリ",
|
||||
"opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。",
|
||||
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
|
||||
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
|
||||
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
|
||||
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
|
||||
"browseDirectory": "ディレクトリを選択",
|
||||
"resetDefault": "デフォルトに戻す(保存後に反映)",
|
||||
"checkForUpdates": "アップデートを確認",
|
||||
@@ -319,7 +352,7 @@
|
||||
"viewReleaseNotes": "このバージョンのリリースノートを見る",
|
||||
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
|
||||
"oneClickInstall": "ワンクリックインストール",
|
||||
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI をインストール",
|
||||
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode をインストール",
|
||||
"localEnvCheck": "ローカル環境チェック",
|
||||
"installCommandsCopied": "インストールコマンドをコピーしました",
|
||||
"installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。",
|
||||
@@ -344,7 +377,21 @@
|
||||
"saved": "プロキシ設定を保存しました",
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"testSuccess": "接続成功!遅延 {{latency}}ms",
|
||||
"testFailed": "接続に失敗しました: {{error}}"
|
||||
"testFailed": "接続に失敗しました: {{error}}",
|
||||
"pricingDefaultsTitle": "課金のデフォルト設定",
|
||||
"pricingDefaultsDescription": "アプリごとのデフォルト倍率と課金モードを設定します。",
|
||||
"pricingAppLabel": "アプリ",
|
||||
"defaultCostMultiplierLabel": "デフォルト倍率",
|
||||
"defaultCostMultiplierHint": "コスト計算用の倍率(小数対応)。",
|
||||
"pricingModelSourceLabel": "課金モード",
|
||||
"pricingModelSourceRequest": "リクエストモデル",
|
||||
"pricingModelSourceResponse": "レスポンスモデル",
|
||||
"pricingSave": "課金設定を保存",
|
||||
"pricingSaved": "課金設定を保存しました",
|
||||
"pricingSaveFailed": "課金設定の保存に失敗しました: {{error}}",
|
||||
"pricingLoadFailed": "課金設定の読み込みに失敗しました: {{error}}",
|
||||
"defaultCostMultiplierRequired": "デフォルト倍率は必須です",
|
||||
"defaultCostMultiplierInvalid": "デフォルト倍率の形式が正しくありません"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
@@ -401,7 +448,8 @@
|
||||
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!",
|
||||
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
|
||||
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!",
|
||||
"rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!"
|
||||
"rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!",
|
||||
"aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!"
|
||||
},
|
||||
"parameterConfig": "パラメーター設定 - {{name}} *",
|
||||
"mainModel": "メインモデル(任意)",
|
||||
@@ -426,8 +474,10 @@
|
||||
"anthropicModel": "メインモデル",
|
||||
"anthropicSmallFastModel": "高速モデル",
|
||||
"anthropicReasoningModel": "推論モデル(Thinking)",
|
||||
"openrouterCompatMode": "OpenRouter 互換モード",
|
||||
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
|
||||
"apiFormat": "API フォーマット",
|
||||
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
||||
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
|
||||
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
||||
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
|
||||
"anthropicDefaultOpusModel": "既定 Opus モデル",
|
||||
@@ -480,7 +530,18 @@
|
||||
"useCustomProxy": "個別プロキシを使用",
|
||||
"proxyConfigDesc": "このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。",
|
||||
"proxyUsername": "ユーザー名(任意)",
|
||||
"proxyPassword": "パスワード(任意)"
|
||||
"proxyPassword": "パスワード(任意)",
|
||||
"pricingConfig": "課金設定",
|
||||
"useCustomPricing": "個別設定を使用",
|
||||
"pricingConfigDesc": "このプロバイダーに個別の課金パラメータを設定します。無効の場合はグローバル設定を使用します。",
|
||||
"costMultiplier": "コスト倍率",
|
||||
"costMultiplierPlaceholder": "空白の場合はグローバル設定を使用(1)",
|
||||
"costMultiplierHint": "実際のコスト = 基本コスト × 倍率、1.5 などの小数をサポート",
|
||||
"pricingModelSourceLabel": "課金モード",
|
||||
"pricingModelSourceInherit": "グローバル設定を継承",
|
||||
"pricingModelSourceRequest": "リクエストモデル",
|
||||
"pricingModelSourceResponse": "レスポンスモデル",
|
||||
"pricingModelSourceHint": "リクエストモデルまたはレスポンスモデルで価格を照合するかを選択"
|
||||
},
|
||||
"codexConfig": {
|
||||
"authJson": "auth.json (JSON) *",
|
||||
@@ -583,6 +644,9 @@
|
||||
"cacheCreationTokens": "キャッシュ作成",
|
||||
"timingInfo": "応答時間/TTFT",
|
||||
"status": "ステータス",
|
||||
"multiplier": "倍率",
|
||||
"requestModel": "リクエストモデル",
|
||||
"responseModel": "レスポンスモデル",
|
||||
"noData": "データなし",
|
||||
"unknownProvider": "不明なプロバイダー",
|
||||
"stream": "ストリーム",
|
||||
@@ -625,7 +689,19 @@
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "作成",
|
||||
"cacheRead": "ヒット"
|
||||
"cacheRead": "ヒット",
|
||||
"baseCost": "基本",
|
||||
"costMultiplier": "コスト倍率",
|
||||
"withMultiplier": "倍率込み",
|
||||
"requestDetail": "リクエスト詳細",
|
||||
"requestNotFound": "リクエストが見つかりません",
|
||||
"basicInfo": "基本情報",
|
||||
"tokenUsage": "Token 使用量",
|
||||
"cacheCreationCost": "キャッシュ作成コスト",
|
||||
"costBreakdown": "コスト明細",
|
||||
"performance": "パフォーマンス",
|
||||
"latency": "レイテンシー",
|
||||
"errorMessage": "エラーメッセージ"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "利用状況を設定",
|
||||
|
||||
@@ -155,7 +155,8 @@
|
||||
"deleteSuccess": "供应商已删除",
|
||||
"deleteFailed": "删除供应商失败:{{error}}",
|
||||
"settingsSaved": "设置已保存",
|
||||
"settingsSaveFailed": "保存设置失败:{{error}}"
|
||||
"settingsSaveFailed": "保存设置失败:{{error}}",
|
||||
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
@@ -265,6 +266,8 @@
|
||||
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
|
||||
"launchOnStartup": "开机自启",
|
||||
"launchOnStartupDescription": "随系统启动自动运行 CC Switch",
|
||||
"silentStartup": "静默启动",
|
||||
"silentStartupDescription": "程序启动时不显示主窗口,仅在系统托盘运行",
|
||||
"autoLaunchFailed": "设置开机自启失败",
|
||||
"minimizeToTray": "关闭时最小化到托盘",
|
||||
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
||||
@@ -287,6 +290,33 @@
|
||||
"copy": "文件复制",
|
||||
"symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "首选终端",
|
||||
"description": "选择点击终端按钮时使用的终端应用",
|
||||
"fallbackHint": "如果选择的终端不可用,将自动使用系统默认终端",
|
||||
"options": {
|
||||
"macos": {
|
||||
"terminal": "Terminal.app",
|
||||
"iterm2": "iTerm2",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
},
|
||||
"windows": {
|
||||
"cmd": "命令提示符",
|
||||
"powershell": "PowerShell",
|
||||
"wt": "Windows Terminal"
|
||||
},
|
||||
"linux": {
|
||||
"gnomeTerminal": "GNOME Terminal",
|
||||
"konsole": "Konsole",
|
||||
"xfce4Terminal": "Xfce4 Terminal",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configDirectoryOverride": "配置目录覆盖(高级)",
|
||||
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
||||
"appConfigDir": "CC Switch 配置目录",
|
||||
@@ -298,9 +328,12 @@
|
||||
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
|
||||
"geminiConfigDir": "Gemini 配置目录",
|
||||
"geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。",
|
||||
"opencodeConfigDir": "OpenCode 配置目录",
|
||||
"opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。",
|
||||
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
|
||||
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
|
||||
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
|
||||
"browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode",
|
||||
"browseDirectory": "浏览目录",
|
||||
"resetDefault": "恢复默认目录(需保存后生效)",
|
||||
"checkForUpdates": "检查更新",
|
||||
@@ -319,7 +352,7 @@
|
||||
"viewReleaseNotes": "查看该版本更新日志",
|
||||
"viewCurrentReleaseNotes": "查看当前版本更新日志",
|
||||
"oneClickInstall": "一键安装",
|
||||
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI",
|
||||
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI / OpenCode",
|
||||
"localEnvCheck": "本地环境检查",
|
||||
"installCommandsCopied": "安装命令已复制",
|
||||
"installCommandsCopyFailed": "复制失败,请手动复制。",
|
||||
@@ -344,7 +377,21 @@
|
||||
"saved": "代理设置已保存",
|
||||
"saveFailed": "保存失败:{{error}}",
|
||||
"testSuccess": "连接成功!延迟 {{latency}}ms",
|
||||
"testFailed": "连接失败:{{error}}"
|
||||
"testFailed": "连接失败:{{error}}",
|
||||
"pricingDefaultsTitle": "计费默认配置",
|
||||
"pricingDefaultsDescription": "设置各应用的默认倍率与计费模式来源。",
|
||||
"pricingAppLabel": "应用",
|
||||
"defaultCostMultiplierLabel": "默认倍率",
|
||||
"defaultCostMultiplierHint": "用于成本计算的倍率,支持小数。",
|
||||
"pricingModelSourceLabel": "计费模式",
|
||||
"pricingModelSourceRequest": "请求模型",
|
||||
"pricingModelSourceResponse": "返回模型",
|
||||
"pricingSave": "保存计费配置",
|
||||
"pricingSaved": "计费配置已保存",
|
||||
"pricingSaveFailed": "保存计费配置失败:{{error}}",
|
||||
"pricingLoadFailed": "加载计费配置失败:{{error}}",
|
||||
"defaultCostMultiplierRequired": "默认倍率不能为空",
|
||||
"defaultCostMultiplierInvalid": "默认倍率格式不正确"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
@@ -401,7 +448,8 @@
|
||||
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!",
|
||||
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠",
|
||||
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!",
|
||||
"rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!"
|
||||
"rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!",
|
||||
"aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!"
|
||||
},
|
||||
"parameterConfig": "参数配置 - {{name}} *",
|
||||
"mainModel": "主模型 (可选)",
|
||||
@@ -426,8 +474,10 @@
|
||||
"anthropicModel": "主模型",
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"anthropicReasoningModel": "推理模型 (Thinking)",
|
||||
"openrouterCompatMode": "OpenRouter 兼容模式",
|
||||
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
||||
"apiFormat": "API 格式",
|
||||
"apiFormatHint": "选择供应商 API 的输入格式",
|
||||
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
|
||||
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
||||
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
||||
"anthropicDefaultOpusModel": "Opus 默认模型",
|
||||
@@ -480,7 +530,18 @@
|
||||
"useCustomProxy": "使用单独代理",
|
||||
"proxyConfigDesc": "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
|
||||
"proxyUsername": "用户名(可选)",
|
||||
"proxyPassword": "密码(可选)"
|
||||
"proxyPassword": "密码(可选)",
|
||||
"pricingConfig": "计费配置",
|
||||
"useCustomPricing": "使用单独配置",
|
||||
"pricingConfigDesc": "为此供应商配置单独的计费参数,不启用时使用全局默认配置。",
|
||||
"costMultiplier": "成本倍率",
|
||||
"costMultiplierPlaceholder": "留空使用全局默认(1)",
|
||||
"costMultiplierHint": "实际成本 = 基础成本 × 倍率,支持小数如 1.5",
|
||||
"pricingModelSourceLabel": "计费模式",
|
||||
"pricingModelSourceInherit": "继承全局默认",
|
||||
"pricingModelSourceRequest": "请求模型",
|
||||
"pricingModelSourceResponse": "返回模型",
|
||||
"pricingModelSourceHint": "选择按请求模型还是返回模型进行定价匹配"
|
||||
},
|
||||
"codexConfig": {
|
||||
"authJson": "auth.json (JSON) *",
|
||||
@@ -583,6 +644,9 @@
|
||||
"cacheCreationTokens": "缓存创建",
|
||||
"timingInfo": "用时/首字",
|
||||
"status": "状态",
|
||||
"multiplier": "倍率",
|
||||
"requestModel": "请求模型",
|
||||
"responseModel": "返回模型",
|
||||
"noData": "暂无数据",
|
||||
"unknownProvider": "未知供应商",
|
||||
"stream": "流",
|
||||
@@ -625,7 +689,19 @@
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "创建",
|
||||
"cacheRead": "命中"
|
||||
"cacheRead": "命中",
|
||||
"baseCost": "基础",
|
||||
"costMultiplier": "成本倍率",
|
||||
"withMultiplier": "含倍率",
|
||||
"requestDetail": "请求详情",
|
||||
"requestNotFound": "请求未找到",
|
||||
"basicInfo": "基本信息",
|
||||
"tokenUsage": "Token 使用量",
|
||||
"cacheCreationCost": "缓存写入成本",
|
||||
"costBreakdown": "成本明细",
|
||||
"performance": "性能信息",
|
||||
"latency": "延迟",
|
||||
"errorMessage": "错误信息"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "配置用量查询",
|
||||
@@ -982,6 +1058,7 @@
|
||||
"http429": "请求过于频繁,请等待后重试",
|
||||
"parseMetadataFailed": "解析技能元数据失败",
|
||||
"getHomeDirFailed": "无法获取用户主目录",
|
||||
"noSkillsInZip": "ZIP 文件中未找到技能(需包含 SKILL.md 文件)",
|
||||
"networkError": "网络错误",
|
||||
"fsError": "文件系统错误",
|
||||
"unknownError": "未知错误",
|
||||
@@ -992,7 +1069,8 @@
|
||||
"checkRepoUrl": "请检查仓库地址和分支名称",
|
||||
"checkDiskSpace": "请检查磁盘空间",
|
||||
"checkPermission": "请检查目录权限",
|
||||
"uninstallFirst": "请先卸载已安装的同名技能"
|
||||
"uninstallFirst": "请先卸载已安装的同名技能",
|
||||
"checkZipContent": "请确认 ZIP 文件包含有效的技能目录(含 SKILL.md 文件)"
|
||||
}
|
||||
},
|
||||
"repo": {
|
||||
@@ -1041,6 +1119,13 @@
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"installFromZip": {
|
||||
"button": "从 ZIP 安装",
|
||||
"installing": "安装中...",
|
||||
"successSingle": "技能 {{name}} 已安装",
|
||||
"successMultiple": "成功安装 {{count}} 个技能",
|
||||
"noSkillsFound": "ZIP 文件中未找到技能(需包含 SKILL.md 文件)"
|
||||
}
|
||||
},
|
||||
"deeplink": {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="1209px" height="1255px" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 1017.97 1056.47"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#E4906E;fill-rule:nonzero}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="圖層_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<path class="fil0" d="M944.92 1014.53c-17.29,-9.23 -33.98,-19.28 -50.08,-30.16 -5.39,-3.65 -14.99,-11.7 -28.81,-24.16 -6.06,-5.47 -14.07,-13.51 -24.03,-24.13 -15.15,-16.17 -29.61,-29.9 -41.69,-40.4 -3.98,-3.46 -14.2,-11.02 -30.68,-22.69 -6.24,-4.42 -12.88,-12.15 -18.63,-19.09 -23.98,-29.04 -49.53,-58.44 -76.66,-88.19 -11.93,-13.1 -25.64,-26.11 -35.61,-36.5 -28.72,-29.92 -51.92,-51.96 -78.23,-79.66 -11.24,-11.83 -20.52,-21.3 -27.85,-28.41 -0.56,-0.53 -1.3,-0.84 -2.08,-0.84 -0.51,0 -1.02,0.14 -1.47,0.39 -9.76,5.54 -17.53,14.33 -24.91,23.31 -10.71,13.01 -21.86,26.65 -33.44,40.91 -4.37,5.38 -7.9,9.46 -10.56,12.24 -5.43,5.67 -9.83,10.88 -15.08,15.39 -9.57,8.23 -19.57,16.01 -29.98,23.31 -14.73,10.32 -29.07,20.29 -43.04,29.9 -5.22,3.61 -13.68,9.81 -20.14,15.41 -25.71,22.32 -53.59,46.12 -83.65,71.41 -9.46,7.95 -19.65,16.88 -34.02,29.22 -25.66,22.03 -52.94,40.06 -81.67,55.65 -7.71,4.19 -14.15,8.2 -19.32,12.01 -19.3,14.23 -33.84,25.65 -43.62,34.28 -25.99,22.91 -43.04,37.82 -51.16,44.73 -9.19,7.8 -18.6,17.05 -28.42,25.54 -2.71,2.35 -5.7,3.03 -8.96,2.01 -0.78,-0.24 -1.25,-1.02 -1.1,-1.82 0.36,-2 1.19,-4.1 2.47,-6.32 6.86,-11.81 14.46,-23.09 19.95,-36.03 3.48,-8.23 7.87,-16.52 13.18,-24.89 2.03,-3.19 4.73,-8.77 8.11,-16.74 2.98,-7.02 7.34,-15.05 13.07,-24.12 5.79,-9.14 16,-23.36 30.63,-42.67 7.66,-10.11 19.49,-23.6 35.49,-40.47 4.9,-5.16 12.21,-11.87 21.92,-20.14 12.12,-10.31 23.53,-21.19 34.23,-32.65 11.73,-12.54 16.99,-22.33 27.39,-40.8 2.37,-4.19 6.49,-9.43 12.37,-15.71 8.27,-8.82 17,-17.23 26.21,-25.23 30.11,-26.18 55.17,-47.43 75.17,-63.76 8.66,-7.08 26.42,-21.39 39.65,-30.77 17.11,-12.13 28.62,-20.44 34.53,-24.91 4.5,-3.4 8.93,-6.6 13.3,-10.56 26.03,-23.54 51.66,-45.71 77.28,-70.7 0.42,-0.41 0.51,-1.06 0.21,-1.58 -6.8,-11.78 -12.84,-21.8 -18.11,-30.06 -10.22,-15.99 -22.07,-29.65 -35.57,-40.99 -7.56,-6.36 -18.41,-13.85 -28.65,-20.43 -15.08,-9.66 -30.62,-21.97 -46.63,-36.9 -35.08,-32.73 -67.65,-71.22 -85.32,-115.42 -5.53,-13.85 -10.8,-29.31 -15.8,-46.37 -5.89,-20.13 -12.37,-35.63 -23.22,-51.27 -8.93,-12.9 -15.77,-21.94 -19.58,-35.93 -1.27,-4.67 -2.93,-12.75 -4.99,-24.23 -2.07,-11.54 -6.54,-22.62 -13.41,-33.25 -7.54,-11.68 -13.66,-21.04 -18.33,-28.08 -3.68,-5.53 -7.02,-12.39 -9.63,-18.53 -3.9,-9.18 -8.14,-15.7 -13.6,-23.37 -3.94,-5.53 -5.07,-12.75 0,-18.32 4.14,-4.57 17.49,-3.02 21.56,-1.13 3.86,1.81 8.1,5.13 12.71,9.94 16.16,16.88 26.41,27.77 30.74,32.66 4.69,5.31 11.21,13.79 16.69,19.94 20.19,22.63 36.17,39.74 47.36,59.71 10.46,18.66 16.41,30.42 29.84,44.67 9.32,9.92 17.94,19.33 25.85,28.23 9.01,10.15 19.25,22.95 30.72,38.39 7.54,10.17 13.89,20.11 19.05,29.84 6.39,12.05 10.8,30.19 15.13,41.41 4.88,12.67 12.52,23.25 22.92,31.75 0.58,0.47 6.79,5.44 18.62,14.89 13.54,10.82 23.74,23.47 30.61,37.96 4.55,9.58 7.82,16.16 9.8,19.74 6.62,11.85 14.64,22.05 24.07,30.59 8.99,8.14 17.47,13.2 31.06,22.64 4.28,2.96 6.68,5.98 10.65,2.54 7.08,-6.11 13.73,-10.71 17.96,-14.53 6.12,-5.54 11.71,-11.84 16.79,-18.92 3.5,-4.88 8.77,-10.16 15.19,-14.42 22.77,-15.02 38.17,-31.11 63.32,-55.15 22.13,-21.17 46.22,-47.56 69.25,-66.8 32.17,-26.89 54.99,-45.9 68.46,-57.01 17.15,-14.15 35.82,-30.97 56.02,-50.46 16.06,-15.5 29.25,-27.72 39.57,-36.66 9.78,-8.47 17.55,-14.8 23.31,-18.98 8.52,-6.2 18.55,-10.61 30.56,-15.03 12.34,-4.55 23.44,-11.06 35.67,-17.61 9.07,-4.85 19.76,-9.89 30.05,-8.84 0.5,0.06 0.97,0.32 1.3,0.72 0.85,1.07 1.01,2.48 0.48,4.23 -2.1,6.99 -5.15,13.55 -9.66,20.87 -6.42,10.42 -11.51,19.46 -15.29,27.11 -6.09,12.35 -9.66,19.49 -10.69,21.43 -7.78,14.65 -17.56,27.97 -29.34,39.97 -4.8,4.89 -12.93,12.92 -24.37,24.07 -14.23,13.87 -25.02,30.77 -37.12,50.78 -15.21,25.13 -29.56,48.47 -43.06,70.01 -5.21,8.29 -13.68,15.13 -21.58,21.47 -25.71,20.7 -46.75,41.48 -70.98,64.67 -1.97,1.88 -4.98,4.47 -9.03,7.76 -22.62,18.36 -45.88,35.93 -69.78,52.74 -5.96,4.2 -13.77,11.24 -23.42,21.11 -17.12,17.5 -25.93,26.56 -26.42,27.19 -1.22,1.54 -1.08,3.09 0.41,4.67 14.11,14.81 34.25,37.65 60.42,68.51 11.89,14.01 24.87,27.08 36.03,39.46 8.75,9.7 16.81,22.11 31.82,42.59 2.69,3.68 13.53,16.07 32.5,37.17 5.17,5.76 11.64,14.47 19.4,26.12 16.37,24.6 36.28,56.2 59.73,94.79 4.2,6.92 7.74,12.33 10.62,16.21 5.41,7.29 10.37,13.74 14.92,20.97 6.26,9.94 11.3,19.92 15.11,29.92 4.29,11.27 7.73,19.49 10.32,24.69 7.21,14.5 14.81,28.41 22.8,41.73 3.44,5.75 6.78,13.03 6.11,20.05 -0.07,0.76 -0.71,1.34 -1.48,1.34 -0.25,0 -0.49,-0.06 -0.71,-0.18l0 0.01z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -2,6 +2,7 @@
|
||||
// Do not edit manually
|
||||
|
||||
export const icons: Record<string, string> = {
|
||||
aicodemirror: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 1017.97 1056.47"><title>AICodeMirror</title><path fill="#E4906E" fill-rule="nonzero" d="M944.92 1014.53c-17.29,-9.23 -33.98,-19.28 -50.08,-30.16 -5.39,-3.65 -14.99,-11.7 -28.81,-24.16 -6.06,-5.47 -14.07,-13.51 -24.03,-24.13 -15.15,-16.17 -29.61,-29.9 -41.69,-40.4 -3.98,-3.46 -14.2,-11.02 -30.68,-22.69 -6.24,-4.42 -12.88,-12.15 -18.63,-19.09 -23.98,-29.04 -49.53,-58.44 -76.66,-88.19 -11.93,-13.1 -25.64,-26.11 -35.61,-36.5 -28.72,-29.92 -51.92,-51.96 -78.23,-79.66 -11.24,-11.83 -20.52,-21.3 -27.85,-28.41 -0.56,-0.53 -1.3,-0.84 -2.08,-0.84 -0.51,0 -1.02,0.14 -1.47,0.39 -9.76,5.54 -17.53,14.33 -24.91,23.31 -10.71,13.01 -21.86,26.65 -33.44,40.91 -4.37,5.38 -7.9,9.46 -10.56,12.24 -5.43,5.67 -9.83,10.88 -15.08,15.39 -9.57,8.23 -19.57,16.01 -29.98,23.31 -14.73,10.32 -29.07,20.29 -43.04,29.9 -5.22,3.61 -13.68,9.81 -20.14,15.41 -25.71,22.32 -53.59,46.12 -83.65,71.41 -9.46,7.95 -19.65,16.88 -34.02,29.22 -25.66,22.03 -52.94,40.06 -81.67,55.65 -7.71,4.19 -14.15,8.2 -19.32,12.01 -19.3,14.23 -33.84,25.65 -43.62,34.28 -25.99,22.91 -43.04,37.82 -51.16,44.73 -9.19,7.8 -18.6,17.05 -28.42,25.54 -2.71,2.35 -5.7,3.03 -8.96,2.01 -0.78,-0.24 -1.25,-1.02 -1.1,-1.82 0.36,-2 1.19,-4.1 2.47,-6.32 6.86,-11.81 14.46,-23.09 19.95,-36.03 3.48,-8.23 7.87,-16.52 13.18,-24.89 2.03,-3.19 4.73,-8.77 8.11,-16.74 2.98,-7.02 7.34,-15.05 13.07,-24.12 5.79,-9.14 16,-23.36 30.63,-42.67 7.66,-10.11 19.49,-23.6 35.49,-40.47 4.9,-5.16 12.21,-11.87 21.92,-20.14 12.12,-10.31 23.53,-21.19 34.23,-32.65 11.73,-12.54 16.99,-22.33 27.39,-40.8 2.37,-4.19 6.49,-9.43 12.37,-15.71 8.27,-8.82 17,-17.23 26.21,-25.23 30.11,-26.18 55.17,-47.43 75.17,-63.76 8.66,-7.08 26.42,-21.39 39.65,-30.77 17.11,-12.13 28.62,-20.44 34.53,-24.91 4.5,-3.4 8.93,-6.6 13.3,-10.56 26.03,-23.54 51.66,-45.71 77.28,-70.7 0.42,-0.41 0.51,-1.06 0.21,-1.58 -6.8,-11.78 -12.84,-21.8 -18.11,-30.06 -10.22,-15.99 -22.07,-29.65 -35.57,-40.99 -7.56,-6.36 -18.41,-13.85 -28.65,-20.43 -15.08,-9.66 -30.62,-21.97 -46.63,-36.9 -35.08,-32.73 -67.65,-71.22 -85.32,-115.42 -5.53,-13.85 -10.8,-29.31 -15.8,-46.37 -5.89,-20.13 -12.37,-35.63 -23.22,-51.27 -8.93,-12.9 -15.77,-21.94 -19.58,-35.93 -1.27,-4.67 -2.93,-12.75 -4.99,-24.23 -2.07,-11.54 -6.54,-22.62 -13.41,-33.25 -7.54,-11.68 -13.66,-21.04 -18.33,-28.08 -3.68,-5.53 -7.02,-12.39 -9.63,-18.53 -3.9,-9.18 -8.14,-15.7 -13.6,-23.37 -3.94,-5.53 -5.07,-12.75 0,-18.32 4.14,-4.57 17.49,-3.02 21.56,-1.13 3.86,1.81 8.1,5.13 12.71,9.94 16.16,16.88 26.41,27.77 30.74,32.66 4.69,5.31 11.21,13.79 16.69,19.94 20.19,22.63 36.17,39.74 47.36,59.71 10.46,18.66 16.41,30.42 29.84,44.67 9.32,9.92 17.94,19.33 25.85,28.23 9.01,10.15 19.25,22.95 30.72,38.39 7.54,10.17 13.89,20.11 19.05,29.84 6.39,12.05 10.8,30.19 15.13,41.41 4.88,12.67 12.52,23.25 22.92,31.75 0.58,0.47 6.79,5.44 18.62,14.89 13.54,10.82 23.74,23.47 30.61,37.96 4.55,9.58 7.82,16.16 9.8,19.74 6.62,11.85 14.64,22.05 24.07,30.59 8.99,8.14 17.47,13.2 31.06,22.64 4.28,2.96 6.68,5.98 10.65,2.54 7.08,-6.11 13.73,-10.71 17.96,-14.53 6.12,-5.54 11.71,-11.84 16.79,-18.92 3.5,-4.88 8.77,-10.16 15.19,-14.42 22.77,-15.02 38.17,-31.11 63.32,-55.15 22.13,-21.17 46.22,-47.56 69.25,-66.8 32.17,-26.89 54.99,-45.9 68.46,-57.01 17.15,-14.15 35.82,-30.97 56.02,-50.46 16.06,-15.5 29.25,-27.72 39.57,-36.66 9.78,-8.47 17.55,-14.8 23.31,-18.98 8.52,-6.2 18.55,-10.61 30.56,-15.03 12.34,-4.55 23.44,-11.06 35.67,-17.61 9.07,-4.85 19.76,-9.89 30.05,-8.84 0.5,0.06 0.97,0.32 1.3,0.72 0.85,1.07 1.01,2.48 0.48,4.23 -2.1,6.99 -5.15,13.55 -9.66,20.87 -6.42,10.42 -11.51,19.46 -15.29,27.11 -6.09,12.35 -9.66,19.49 -10.69,21.43 -7.78,14.65 -17.56,27.97 -29.34,39.97 -4.8,4.89 -12.93,12.92 -24.37,24.07 -14.23,13.87 -25.02,30.77 -37.12,50.78 -15.21,25.13 -29.56,48.47 -43.06,70.01 -5.21,8.29 -13.68,15.13 -21.58,21.47 -25.71,20.7 -46.75,41.48 -70.98,64.67 -1.97,1.88 -4.98,4.47 -9.03,7.76 -22.62,18.36 -45.88,35.93 -69.78,52.74 -5.96,4.2 -13.77,11.24 -23.42,21.11 -17.12,17.5 -25.93,26.56 -26.42,27.19 -1.22,1.54 -1.08,3.09 0.41,4.67 14.11,14.81 34.25,37.65 60.42,68.51 11.89,14.01 24.87,27.08 36.03,39.46 8.75,9.7 16.81,22.11 31.82,42.59 2.69,3.68 13.53,16.07 32.5,37.17 5.17,5.76 11.64,14.47 19.4,26.12 16.37,24.6 36.28,56.2 59.73,94.79 4.2,6.92 7.74,12.33 10.62,16.21 5.41,7.29 10.37,13.74 14.92,20.97 6.26,9.94 11.3,19.92 15.11,29.92 4.29,11.27 7.73,19.49 10.32,24.69 7.21,14.5 14.81,28.41 22.8,41.73 3.44,5.75 6.78,13.03 6.11,20.05 -0.07,0.76 -0.71,1.34 -1.48,1.34 -0.25,0 -0.49,-0.06 -0.71,-0.18l0 0.01z"/></svg>`,
|
||||
aigocode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 648 564" xmlns="http://www.w3.org/2000/svg"><title>AiGoCode</title><g transform="translate(0,564) scale(0.1,-0.1)"><path fill="#7C6AEF" d="M5392 5379 c-26 -10 -36 -28 -56 -108 -24 -94 -47 -140 -101 -199 -56 -62 -117 -96 -219 -121 -101 -25 -116 -35 -116 -75 0 -45 21 -60 123 -89 190 -53 271 -147 336 -384 13 -46 38 -61 85 -49 25 6 36 28 56 111 7 28 23 72 36 99 13 28 21 54 18 58 -3 5 -2 7 3 6 4 -2 29 18 55 44 41 41 145 110 173 114 56 8 126 27 131 36 4 6 7 30 8 54 1 49 -5 54 -104 74 -164 33 -280 148 -320 320 -23 101 -52 129 -108 109z"/><path fill="#5B7FFF" d="M1770 4814 c-138 -25 -301 -93 -425 -177 -80 -55 -227 -197 -280 -272 -98 -138 -161 -279 -201 -447 -16 -66 -18 -164 -21 -1098 -4 -1130 -4 -1144 57 -1331 80 -242 222 -434 444 -598 l56 -42 0 -337 c0 -309 2 -340 19 -379 31 -66 87 -93 158 -74 22 6 202 117 404 249 200 131 398 259 439 285 144 89 48 81 1095 88 912 5 932 6 1012 27 243 64 441 178 599 344 166 176 274 408 304 648 13 110 13 2016 0 2120 -25 190 -83 347 -183 498 -160 240 -384 400 -677 484 l-75 22 -1325 2 c-1086 2 -1339 0 -1400 -12z m1068 -1455 l-3 -751 -71 -18 c-71 -18 -154 -55 -205 -91 -14 -10 -29 -16 -33 -12 -3 3 -6 91 -6 195 l0 188 -306 0 -305 0 -21 -47 c-11 -27 -33 -75 -48 -108 -16 -33 -44 -96 -62 -140 l-34 -80 -172 -3 c-101 -1 -172 1 -172 7 0 5 14 40 31 78 31 68 101 227 254 578 335 769 358 814 434 874 94 74 122 79 449 80 l272 1 -2 -751z m794 717 c41 -13 103 -42 139 -62 67 -40 202 -168 232 -221 l17 -31 -77 -65 c-43 -35 -101 -80 -129 -101 l-52 -36 -39 57 c-79 116 -204 177 -313 154 -66 -14 -105 -42 -130 -91 -19 -37 -20 -60 -20 -400 0 -339 1 -363 20 -399 26 -51 61 -78 128 -97 143 -42 327 52 366 186 l13 45 -163 3 -163 2 -3 157 c-2 111 0 157 9 160 6 2 263 3 570 1 487 -3 562 -5 593 -19 98 -45 125 -159 58 -244 -39 -50 -66 -55 -322 -55 l-236 0 -6 -27 c-18 -83 -29 -115 -62 -183 -69 -143 -230 -269 -402 -316 -81 -22 -271 -25 -350 -5 -173 43 -289 145 -341 298 -19 57 -20 81 -17 534 l3 474 33 67 c55 112 168 199 307 235 79 20 246 10 337 -21z m828 -1515 c67 -71 67 -73 -62 -396 -45 -110 -102 -254 -128 -320 -254 -639 -272 -681 -298 -702 -74 -62 -192 -15 -192 77 0 12 39 116 86 233 48 117 97 239 110 272 119 311 336 833 354 852 36 37 85 32 130 -16z m-1574 -205 c16 -13 19 -29 22 -128 l3 -113 -195 -155 c-108 -85 -196 -158 -196 -161 0 -4 17 -19 38 -35 128 -96 327 -272 339 -301 16 -39 17 -143 2 -177 -15 -33 -34 -39 -67 -22 -48 26 -566 446 -584 474 -23 35 -23 89 1 128 16 27 150 143 376 326 39 31 79 65 90 75 44 41 128 103 139 103 7 0 21 -6 32 -14z m713 -25 c52 -37 53 -33 -92 -551 -36 -129 -80 -289 -98 -355 -39 -143 -62 -175 -129 -175 -47 0 -92 20 -114 52 -24 34 -19 90 18 217 19 64 78 271 131 461 53 190 98 353 101 364 6 16 14 18 79 14 54 -4 81 -11 104 -27z m1116 -351 c55 -45 117 -98 138 -120 61 -64 48 -115 -50 -192 -32 -25 -118 -94 -191 -152 -136 -108 -163 -120 -217 -100 -26 10 -47 62 -39 97 10 46 22 62 94 119 36 29 93 77 128 106 l62 55 -65 59 c-71 65 -77 84 -49 141 24 47 44 67 68 67 12 0 66 -36 121 -80z"/><path fill="#5B7FFF" d="M2328 3755 c-37 -20 -54 -53 -153 -280 -48 -110 -96 -219 -106 -242 l-18 -43 234 0 235 0 0 290 0 290 -82 0 c-53 -1 -93 -6 -110 -15z"/></g></svg>`,
|
||||
alibaba: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Alibaba</title><path d="M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z" fill="#FF6003" fill-rule="evenodd"></path></svg>`,
|
||||
anthropic: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>`,
|
||||
@@ -58,6 +59,7 @@ export const icons: Record<string, string> = {
|
||||
siliconflow: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>`,
|
||||
catcoder: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>KwaiKAT</title><path d="M20.42 19.311h3.418V1l-6.781 4.177-6.778-4.111.026 7.868h3.418l-.026-2.222 3.42 2.035 3.303-2.035v12.6z"></path><path d="M3.064 10.734c2.784-2.07 6.942-2.394 9.941.907l.01.01.01.013 9.16 12.24h-3.84l-7.69-10.217c-1.63-1.737-3.891-1.689-5.515-.638-1.624 1.05-2.563 3.073-1.548 5.28 1.494 3.246 6.152 3.275 7.725.108l.032-.064 2.02 2.629c-2.98 3.968-9.329 3.926-12.165-.552-2.395-3.78-.926-7.645 1.86-9.716z"></path></svg>`,
|
||||
mcp: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>`,
|
||||
nvidia: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>`,
|
||||
};
|
||||
|
||||
export const iconList = Object.keys(icons);
|
||||
|
||||
@@ -359,6 +359,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["xiaomimimo", "xiaomi", "mimo"],
|
||||
defaultColor: "#000000",
|
||||
},
|
||||
nvidia: {
|
||||
name: "nvidia",
|
||||
displayName: "NVIDIA",
|
||||
category: "ai-provider",
|
||||
keywords: ["nvidia", "nim", "gpu"],
|
||||
defaultColor: "#74B71B",
|
||||
},
|
||||
};
|
||||
|
||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -3,6 +3,7 @@ export { providersApi, universalProvidersApi } from "./providers";
|
||||
export { settingsApi } from "./settings";
|
||||
export { mcpApi } from "./mcp";
|
||||
export { promptsApi } from "./prompts";
|
||||
export { skillsApi } from "./skills";
|
||||
export { usageApi } from "./usage";
|
||||
export { vscodeApi } from "./vscode";
|
||||
export { proxyApi } from "./proxy";
|
||||
|
||||
@@ -92,4 +92,29 @@ export const proxyApi = {
|
||||
async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> {
|
||||
return invoke("update_proxy_config_for_app", { config });
|
||||
},
|
||||
|
||||
// ========== 计费默认配置 API ==========
|
||||
|
||||
// 获取默认成本倍率
|
||||
async getDefaultCostMultiplier(appType: string): Promise<string> {
|
||||
return invoke("get_default_cost_multiplier", { appType });
|
||||
},
|
||||
|
||||
// 设置默认成本倍率
|
||||
async setDefaultCostMultiplier(
|
||||
appType: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
return invoke("set_default_cost_multiplier", { appType, value });
|
||||
},
|
||||
|
||||
// 获取计费模式来源
|
||||
async getPricingModelSource(appType: string): Promise<string> {
|
||||
return invoke("get_pricing_model_source", { appType });
|
||||
},
|
||||
|
||||
// 设置计费模式来源
|
||||
async setPricingModelSource(appType: string, value: string): Promise<void> {
|
||||
return invoke("set_pricing_model_source", { appType, value });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -159,4 +159,19 @@ export const skillsApi = {
|
||||
async removeRepo(owner: string, name: string): Promise<boolean> {
|
||||
return await invoke("remove_skill_repo", { owner, name });
|
||||
},
|
||||
|
||||
// ========== ZIP 安装 ==========
|
||||
|
||||
/** 打开 ZIP 文件选择对话框 */
|
||||
async openZipFileDialog(): Promise<string | null> {
|
||||
return await invoke("open_zip_file_dialog");
|
||||
},
|
||||
|
||||
/** 从 ZIP 文件安装 Skills */
|
||||
async installFromZip(
|
||||
filePath: string,
|
||||
currentApp: AppType,
|
||||
): Promise<InstalledSkill[]> {
|
||||
return await invoke("install_skills_from_zip", { filePath, currentApp });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ function getErrorI18nKey(code: string): string {
|
||||
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
|
||||
EMPTY_ARCHIVE: "skills.error.emptyArchive",
|
||||
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
|
||||
NO_SKILLS_IN_ZIP: "skills.error.noSkillsInZip",
|
||||
};
|
||||
|
||||
return mapping[code] || "skills.error.unknownError";
|
||||
@@ -54,6 +55,7 @@ function getSuggestionI18nKey(suggestion: string): string {
|
||||
checkRepoUrl: "skills.error.suggestion.checkRepoUrl",
|
||||
checkPermission: "skills.error.suggestion.checkPermission",
|
||||
uninstallFirst: "skills.error.suggestion.uninstallFirst",
|
||||
checkZipContent: "skills.error.suggestion.checkZipContent",
|
||||
http403: "skills.error.http403",
|
||||
http404: "skills.error.http404",
|
||||
http429: "skills.error.http429",
|
||||
|
||||
@@ -61,10 +61,11 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail = extractErrorMessage(error) || t("common.unknown");
|
||||
toast.error(
|
||||
t("notifications.addFailed", {
|
||||
defaultValue: "添加供应商失败: {{error}}",
|
||||
error: error.message,
|
||||
error: detail,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -92,10 +93,11 @@ export const useUpdateProviderMutation = (appId: AppId) => {
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail = extractErrorMessage(error) || t("common.unknown");
|
||||
toast.error(
|
||||
t("notifications.updateFailed", {
|
||||
defaultValue: "更新供应商失败: {{error}}",
|
||||
error: error.message,
|
||||
error: detail,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -133,10 +135,11 @@ export const useDeleteProviderMutation = (appId: AppId) => {
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail = extractErrorMessage(error) || t("common.unknown");
|
||||
toast.error(
|
||||
t("notifications.deleteFailed", {
|
||||
defaultValue: "删除供应商失败: {{error}}",
|
||||
error: error.message,
|
||||
error: detail,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -171,17 +174,8 @@ export const useSwitchProviderMutation = (appId: AppId) => {
|
||||
);
|
||||
}
|
||||
|
||||
// OpenCode: show "added to config" message instead of "switched"
|
||||
const messageKey =
|
||||
appId === "opencode"
|
||||
? "notifications.addToConfigSuccess"
|
||||
: "notifications.switchSuccess";
|
||||
const defaultMessage =
|
||||
appId === "opencode" ? "已添加到配置" : "切换供应商成功";
|
||||
|
||||
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
|
||||
closeButton: true,
|
||||
});
|
||||
// Note: Success toast is handled by useProviderActions.switchProvider
|
||||
// to allow customization based on provider properties (e.g., apiFormat)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail = extractErrorMessage(error) || t("common.unknown");
|
||||
|
||||
@@ -135,11 +135,24 @@ export interface ProviderMeta {
|
||||
testConfig?: ProviderTestConfig;
|
||||
// 供应商单独的代理配置
|
||||
proxyConfig?: ProviderProxyConfig;
|
||||
// 供应商成本倍率
|
||||
costMultiplier?: string;
|
||||
// 供应商计费模式来源
|
||||
pricingModelSource?: string;
|
||||
// Claude API 格式(仅 Claude 供应商使用)
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
apiFormat?: "anthropic" | "openai_chat";
|
||||
}
|
||||
|
||||
// Skill 同步方式
|
||||
export type SkillSyncMethod = "auto" | "symlink" | "copy";
|
||||
|
||||
// Claude API 格式类型
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
export type ClaudeApiFormat = "anthropic" | "openai_chat";
|
||||
|
||||
// 主页面显示的应用配置
|
||||
export interface VisibleApps {
|
||||
claude: boolean;
|
||||
@@ -162,6 +175,8 @@ export interface Settings {
|
||||
skipClaudeOnboarding?: boolean;
|
||||
// 是否开机自启
|
||||
launchOnStartup?: boolean;
|
||||
// 静默启动(程序启动时不显示主窗口)
|
||||
silentStartup?: boolean;
|
||||
// 首选语言(可选,默认中文)
|
||||
language?: "en" | "zh" | "ja";
|
||||
|
||||
@@ -189,6 +204,13 @@ export interface Settings {
|
||||
// ===== Skill 同步设置 =====
|
||||
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||
skillSyncMethod?: SkillSyncMethod;
|
||||
|
||||
// ===== 终端设置 =====
|
||||
// 首选终端应用(可选,默认使用系统默认终端)
|
||||
// macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
// Windows: "cmd" | "powershell" | "wt"
|
||||
// Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
|
||||
preferredTerminal?: string;
|
||||
}
|
||||
|
||||
// MCP 服务器连接参数(宽松:允许扩展字段)
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface RequestLog {
|
||||
providerName?: string;
|
||||
appType: string;
|
||||
model: string;
|
||||
requestModel?: string;
|
||||
costMultiplier: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mutateAsyncMock = vi.fn();
|
||||
const testMutateAsyncMock = vi.fn();
|
||||
const scanMutateAsyncMock = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/useGlobalProxy", () => ({
|
||||
useGlobalProxyUrl: () => ({ data: "http://127.0.0.1:7890", isLoading: false }),
|
||||
useSetGlobalProxyUrl: () => ({
|
||||
mutateAsync: mutateAsyncMock,
|
||||
isPending: false,
|
||||
}),
|
||||
useTestProxy: () => ({
|
||||
mutateAsync: testMutateAsyncMock,
|
||||
isPending: false,
|
||||
}),
|
||||
useScanProxies: () => ({
|
||||
mutateAsync: scanMutateAsyncMock,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("GlobalProxySettings", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsyncMock.mockReset();
|
||||
testMutateAsyncMock.mockReset();
|
||||
scanMutateAsyncMock.mockReset();
|
||||
});
|
||||
|
||||
it("renders proxy URL input with saved value", async () => {
|
||||
render(<GlobalProxySettings />);
|
||||
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
|
||||
);
|
||||
// URL 对象会在末尾添加斜杠
|
||||
await waitFor(() =>
|
||||
expect(urlInput).toHaveValue("http://127.0.0.1:7890/"),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves proxy URL when save button is clicked", async () => {
|
||||
render(<GlobalProxySettings />);
|
||||
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
|
||||
);
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: "http://localhost:8080" } });
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "common.save" });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled());
|
||||
// 没有用户名时,URL 不经过 URL 对象解析,所以没有尾部斜杠
|
||||
expect(mutateAsyncMock).toHaveBeenCalledWith("http://localhost:8080");
|
||||
});
|
||||
|
||||
it("clears proxy URL when clear button is clicked", async () => {
|
||||
render(<GlobalProxySettings />);
|
||||
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
|
||||
);
|
||||
|
||||
// Wait for initial value to load
|
||||
await waitFor(() =>
|
||||
expect(urlInput).toHaveValue("http://127.0.0.1:7890/"),
|
||||
);
|
||||
|
||||
// Click clear button
|
||||
const clearButton = screen.getByTitle("settings.globalProxy.clear");
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(urlInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -64,9 +64,12 @@ describe("useDirectorySettings", () => {
|
||||
);
|
||||
|
||||
getAppConfigDirOverrideMock.mockResolvedValue(null);
|
||||
getConfigDirMock.mockImplementation(async (app: string) =>
|
||||
app === "claude" ? "/remote/claude" : "/remote/codex",
|
||||
);
|
||||
getConfigDirMock.mockImplementation(async (app: string) => {
|
||||
if (app === "claude") return "/remote/claude";
|
||||
if (app === "codex") return "/remote/codex";
|
||||
if (app === "gemini") return "/remote/gemini";
|
||||
return "/remote/opencode";
|
||||
});
|
||||
selectConfigDirectoryMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -84,7 +87,8 @@ describe("useDirectorySettings", () => {
|
||||
appConfig: "/override/app",
|
||||
claude: "/remote/claude",
|
||||
codex: "/remote/codex",
|
||||
gemini: "/remote/codex", // Gemini 使用 codex 作为默认
|
||||
gemini: "/remote/gemini",
|
||||
opencode: "/remote/opencode",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,10 +218,17 @@ describe("useDirectorySettings", () => {
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.resetAllDirectories("/server/claude", "/server/codex");
|
||||
result.current.resetAllDirectories(
|
||||
"/server/claude",
|
||||
"/server/codex",
|
||||
"/server/gemini",
|
||||
"/server/opencode",
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.resolvedDirs.claude).toBe("/server/claude");
|
||||
expect(result.current.resolvedDirs.codex).toBe("/server/codex");
|
||||
expect(result.current.resolvedDirs.gemini).toBe("/server/gemini");
|
||||
expect(result.current.resolvedDirs.opencode).toBe("/server/opencode");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,6 +381,7 @@ describe("useSettings hook", () => {
|
||||
"/server/claude",
|
||||
undefined,
|
||||
undefined, // geminiConfigDir
|
||||
undefined, // opencodeConfigDir
|
||||
);
|
||||
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
if (
|
||||
typeof globalThis.localStorage === "undefined" ||
|
||||
typeof globalThis.localStorage?.getItem !== "function"
|
||||
) {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
},
|
||||
key: (index: number) => Array.from(storage.keys())[index] ?? null,
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./tests/setupTests.ts"],
|
||||
setupFiles: ["./tests/setupGlobals.ts", "./tests/setupTests.ts"],
|
||||
globals: true,
|
||||
coverage: {
|
||||
reporter: ["text", "lcov"],
|
||||
|
||||
Reference in New Issue
Block a user