mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 766ca23eca | |||
| 48a8aabfb8 | |||
| cf79b09597 | |||
| 6004084644 | |||
| 52199f39c1 | |||
| c69d36d457 | |||
| a2aa969096 | |||
| 02fd924119 | |||
| fa4e7bcd82 | |||
| 43ccbdad47 | |||
| b46fb6e6da | |||
| 0efe0594e9 | |||
| a80b2b98b7 | |||
| f4f0590fd6 | |||
| bfa5c3c526 | |||
| 9f212115c4 | |||
| da75f22a12 | |||
| 3a692c84fb | |||
| fe08f69cac | |||
| 4a1ee98784 | |||
| 2901ead814 | |||
| e99d599ad8 |
@@ -53,10 +53,7 @@ jobs:
|
||||
wget \
|
||||
file \
|
||||
patchelf \
|
||||
libssl-dev \
|
||||
rpm \
|
||||
flatpak \
|
||||
flatpak-builder
|
||||
libssl-dev
|
||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev \
|
||||
@@ -156,7 +153,7 @@ jobs:
|
||||
|
||||
- name: Build Tauri App (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: pnpm tauri build -- --bundles appimage,deb,rpm
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Prepare macOS Assets
|
||||
if: runner.os == 'macOS'
|
||||
@@ -274,28 +271,6 @@ jobs:
|
||||
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"
|
||||
else
|
||||
echo "No .rpm found (optional)"
|
||||
fi
|
||||
# 额外上传 .flatpak(跨发行版;不参与 Updater)
|
||||
if [ -n "$DEB" ]; then
|
||||
echo "Building Flatpak bundle from .deb..."
|
||||
cp "$DEB" flatpak/cc-switch.deb
|
||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
|
||||
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
|
||||
NEW_FLATPAK="CC-Switch-${VERSION}-Linux.flatpak"
|
||||
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo "release-assets/$NEW_FLATPAK" com.ccswitch.desktop
|
||||
echo "Flatpak bundle created: $NEW_FLATPAK"
|
||||
else
|
||||
echo "Skip Flatpak build: no .deb found"
|
||||
fi
|
||||
|
||||
- name: List prepared assets
|
||||
shell: bash
|
||||
@@ -324,7 +299,7 @@ jobs:
|
||||
|
||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
||||
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||
- **Linux**: `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)或 `CC-Switch-${{ github.ref_name }}-Linux.flatpak`(Flatpak)
|
||||
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu)
|
||||
|
||||
---
|
||||
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
|
||||
@@ -18,8 +18,3 @@ GEMINI.md
|
||||
/.vscode
|
||||
vitest-report.json
|
||||
nul
|
||||
|
||||
# Flatpak build artifacts
|
||||
flatpak/cc-switch.deb
|
||||
flatpak-build/
|
||||
flatpak-repo/
|
||||
|
||||
@@ -186,19 +186,7 @@ paru -S cc-switch-bin
|
||||
|
||||
### Linux Users
|
||||
|
||||
Download the latest Linux build from the [Releases](../../releases) page:
|
||||
|
||||
- `CC-Switch-v{version}-Linux.deb` (Debian/Ubuntu)
|
||||
- `CC-Switch-v{version}-Linux.rpm` (Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{version}-Linux.AppImage` (Universal)
|
||||
- `CC-Switch-v{version}-Linux.flatpak` (Flatpak)
|
||||
|
||||
Flatpak install & run:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
+1
-13
@@ -186,19 +186,7 @@ paru -S cc-switch-bin
|
||||
|
||||
### Linux ユーザー
|
||||
|
||||
[Releases](../../releases) から最新版の Linux ビルドをダウンロード:
|
||||
|
||||
- `CC-Switch-v{version}-Linux.deb`(Debian/Ubuntu)
|
||||
- `CC-Switch-v{version}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{version}-Linux.AppImage`(汎用)
|
||||
- `CC-Switch-v{version}-Linux.flatpak`(Flatpak)
|
||||
|
||||
Flatpak のインストールと起動:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
[Releases](../../releases) から最新版の `CC-Switch-v{version}-Linux.deb` または `CC-Switch-v{version}-Linux.AppImage` をダウンロード。
|
||||
|
||||
## クイックスタート
|
||||
|
||||
|
||||
+1
-13
@@ -186,19 +186,7 @@ paru -S cc-switch-bin
|
||||
|
||||
### Linux 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本的 Linux 安装包:
|
||||
|
||||
- `CC-Switch-v{版本号}-Linux.deb`(Debian/Ubuntu)
|
||||
- `CC-Switch-v{版本号}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{版本号}-Linux.AppImage`(通用)
|
||||
- `CC-Switch-v{版本号}-Linux.flatpak`(Flatpak)
|
||||
|
||||
Flatpak 安装与运行:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{版本号}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# Flatpak Build Guide
|
||||
|
||||
This directory contains the Flatpak manifest (`com.ccswitch.desktop`) for CC Switch, used to convert the generated `.deb` artifact into an installable `.flatpak` package via CI or local builds.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `flatpak`
|
||||
- `flatpak-builder`
|
||||
- Flathub remote (for installing `org.gnome.Platform//46` runtime)
|
||||
|
||||
For Ubuntu/Debian:
|
||||
|
||||
```bash
|
||||
sudo apt install flatpak flatpak-builder
|
||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
|
||||
```
|
||||
|
||||
## Local Build (Generate .flatpak from .deb)
|
||||
|
||||
1) Build the deb on Linux first:
|
||||
|
||||
```bash
|
||||
pnpm tauri build -- --bundles deb
|
||||
```
|
||||
|
||||
2) Copy the generated deb to this directory:
|
||||
|
||||
```bash
|
||||
cp "$(find src-tauri/target/release/bundle -name '*.deb' | head -n 1)" flatpak/cc-switch.deb
|
||||
```
|
||||
|
||||
3) Build the local Flatpak repository and export the `.flatpak`:
|
||||
|
||||
```bash
|
||||
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
|
||||
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo CC-Switch-Linux.flatpak com.ccswitch.desktop
|
||||
```
|
||||
|
||||
4) Install and run:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
|
||||
## Permissions Note
|
||||
|
||||
The current manifest uses `--filesystem=home` by default for "download and run" convenience, allowing the app to directly read/write CLI configuration files and app data on the host (and supporting the "directory override" feature).
|
||||
|
||||
If you prefer minimal permissions (e.g., for Flathub submission or security concerns), you can replace `--filesystem=home` in `flatpak/com.ccswitch.desktop.yml` with more precise grants:
|
||||
|
||||
```yaml
|
||||
- --filesystem=~/.cc-switch:create
|
||||
- --filesystem=~/.claude:create
|
||||
- --filesystem=~/.claude.json
|
||||
- --filesystem=~/.codex:create
|
||||
- --filesystem=~/.gemini:create
|
||||
```
|
||||
|
||||
Note: Flatpak's `:create` modifier only works with directories, not files. Therefore, `~/.claude.json` cannot use `:create`. If this file doesn't exist on the user's machine, the app may not be able to create it with restricted permissions. Users should either run Claude Code once to generate it, or manually create an empty JSON file (content: `{}`).
|
||||
|
||||
If you plan to publish on Flathub or want stricter permission control, adjust the `finish-args` in `flatpak/com.ccswitch.desktop.yml` accordingly.
|
||||
@@ -1,9 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=CC Switch
|
||||
Comment=All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||
Exec=cc-switch
|
||||
Icon=com.ccswitch.desktop
|
||||
Terminal=false
|
||||
Categories=Utility;Development;
|
||||
StartupNotify=true
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.ccswitch.desktop</id>
|
||||
<name>CC Switch</name>
|
||||
<summary>All-in-One Assistant for Claude Code, Codex & Gemini CLI</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<description>
|
||||
<p>CC Switch is a cross-platform desktop app for managing and switching provider configurations for Claude Code, Codex, and Gemini CLI.</p>
|
||||
<ul>
|
||||
<li>Manage multiple provider configurations and endpoints</li>
|
||||
<li>One-click switch and sync to client live configurations</li>
|
||||
<li>MCP servers and Prompt/Skills management</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">com.ccswitch.desktop.desktop</launchable>
|
||||
<provides>
|
||||
<binary>cc-switch</binary>
|
||||
</provides>
|
||||
|
||||
<url type="homepage">https://github.com/farion1231/cc-switch</url>
|
||||
<url type="bugtracker">https://github.com/farion1231/cc-switch/issues</url>
|
||||
</component>
|
||||
@@ -1,45 +0,0 @@
|
||||
id: com.ccswitch.desktop
|
||||
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '46'
|
||||
sdk: org.gnome.Sdk
|
||||
|
||||
command: cc-switch
|
||||
|
||||
finish-args:
|
||||
- --share=ipc
|
||||
- --share=network
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --device=dri
|
||||
# Tray icon permissions (required by Tauri tray-icon)
|
||||
- --talk-name=org.kde.StatusNotifierWatcher
|
||||
- --filesystem=xdg-run/tray-icon:create
|
||||
# GitHub Releases scenario: Users download and install manually.
|
||||
# For "download and run" convenience (needs read/write access to ~/.cc-switch, ~/.claude, ~/.claude.json, ~/.codex, ~/.gemini,
|
||||
# and supports custom directory overrides), we grant full Home access by default.
|
||||
# If you plan to publish on Flathub or prefer minimal permissions, replace this with more precise directory grants (see flatpak/README.md).
|
||||
- --filesystem=home
|
||||
|
||||
modules:
|
||||
- name: cc-switch
|
||||
buildsystem: simple
|
||||
sources:
|
||||
# Placed in flatpak/ directory by CI or local build script
|
||||
- type: file
|
||||
path: cc-switch.deb
|
||||
- type: file
|
||||
path: com.ccswitch.desktop.desktop
|
||||
- type: file
|
||||
path: com.ccswitch.desktop.metainfo.xml
|
||||
- type: file
|
||||
path: ../src-tauri/icons/128x128.png
|
||||
build-commands:
|
||||
- ar -x *.deb
|
||||
- tar -xf data.tar.*
|
||||
- cp -a usr/* /app/
|
||||
# Use our own desktop/metainfo/icon to align with Flatpak app id
|
||||
- rm -f /app/share/applications/*.desktop
|
||||
- install -Dm644 com.ccswitch.desktop.desktop /app/share/applications/com.ccswitch.desktop.desktop
|
||||
- install -Dm644 com.ccswitch.desktop.metainfo.xml /app/share/metainfo/com.ccswitch.desktop.metainfo.xml
|
||||
- install -Dm644 128x128.png /app/share/icons/hicolor/128x128/apps/com.ccswitch.desktop.png
|
||||
@@ -55,110 +55,6 @@ impl McpApps {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SkillApps {
|
||||
#[serde(default)]
|
||||
pub claude: bool,
|
||||
#[serde(default)]
|
||||
pub codex: bool,
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
}
|
||||
|
||||
impl SkillApps {
|
||||
/// 检查指定应用是否启用
|
||||
pub fn is_enabled_for(&self, app: &AppType) -> bool {
|
||||
match app {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置指定应用的启用状态
|
||||
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
|
||||
match app {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有启用的应用列表
|
||||
pub fn enabled_apps(&self) -> Vec<AppType> {
|
||||
let mut apps = Vec::new();
|
||||
if self.claude {
|
||||
apps.push(AppType::Claude);
|
||||
}
|
||||
if self.codex {
|
||||
apps.push(AppType::Codex);
|
||||
}
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
pub fn only(app: &AppType) -> Self {
|
||||
let mut apps = Self::default();
|
||||
apps.set_enabled_for(app, true);
|
||||
apps
|
||||
}
|
||||
}
|
||||
|
||||
/// 已安装的 Skill(v3.10.0+ 统一结构)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstalledSkill {
|
||||
/// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory")
|
||||
pub id: String,
|
||||
/// 显示名称
|
||||
pub name: String,
|
||||
/// 描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// 安装目录名(在 SSOT 目录中的子目录名)
|
||||
pub directory: String,
|
||||
/// 仓库所有者(GitHub 用户/组织)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_owner: Option<String>,
|
||||
/// 仓库名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_name: Option<String>,
|
||||
/// 仓库分支
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_branch: Option<String>,
|
||||
/// README URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub readme_url: Option<String>,
|
||||
/// 应用启用状态
|
||||
pub apps: SkillApps,
|
||||
/// 安装时间(Unix 时间戳)
|
||||
pub installed_at: i64,
|
||||
}
|
||||
|
||||
/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnmanagedSkill {
|
||||
/// 目录名
|
||||
pub directory: String,
|
||||
/// 显示名称(从 SKILL.md 解析)
|
||||
pub name: String,
|
||||
/// 描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// 在哪些应用目录中发现(如 ["claude", "codex"])
|
||||
pub found_in: Vec<String>,
|
||||
}
|
||||
|
||||
/// MCP 服务器定义(v3.7.0 统一结构)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServer {
|
||||
|
||||
@@ -7,7 +7,6 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||
use crate::settings;
|
||||
|
||||
/// 获取 Claude Code 配置状态
|
||||
#[tauri::command]
|
||||
@@ -17,18 +16,6 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
fn invalid_json_format_error(error: serde_json::Error) -> String {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
match lang.as_str() {
|
||||
"en" => format!("Invalid JSON format: {error}"),
|
||||
"ja" => format!("JSON形式が無効です: {error}"),
|
||||
_ => format!("无效的 JSON 格式: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
@@ -168,7 +155,8 @@ pub async fn set_claude_common_config_snippet(
|
||||
) -> Result<(), String> {
|
||||
// 验证是否为有效的 JSON(如果不为空)
|
||||
if !snippet.trim().is_empty() {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
||||
}
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
@@ -209,7 +197,7 @@ pub async fn set_common_config_snippet(
|
||||
"claude" | "gemini" => {
|
||||
// 验证 JSON 格式
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
||||
}
|
||||
"codex" => {
|
||||
// TOML 格式暂不验证(或可使用 toml crate)
|
||||
@@ -231,31 +219,3 @@ pub async fn set_common_config_snippet(
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 提取通用配置片段
|
||||
///
|
||||
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
|
||||
///
|
||||
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
|
||||
#[tauri::command]
|
||||
pub async fn extract_common_config_snippet(
|
||||
appType: String,
|
||||
settingsConfig: Option<String>,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<String, String> {
|
||||
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
|
||||
|
||||
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app,
|
||||
&settings,
|
||||
)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
|
||||
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -192,13 +192,3 @@ pub async fn toggle_mcp_app(
|
||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
|
||||
#[tauri::command]
|
||||
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
let mut total = 0;
|
||||
total += McpService::import_from_claude(&state).unwrap_or(0);
|
||||
total += McpService::import_from_codex(&state).unwrap_or(0);
|
||||
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||
use crate::init_status::InitErrorPayload;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
@@ -65,13 +65,6 @@ pub async fn get_migration_result() -> Result<bool, String> {
|
||||
Ok(crate::init_status::take_migration_success())
|
||||
}
|
||||
|
||||
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
|
||||
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
|
||||
#[tauri::command]
|
||||
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
|
||||
Ok(crate::init_status::take_skills_migration_result())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ToolVersion {
|
||||
name: String,
|
||||
|
||||
+127
-142
@@ -1,17 +1,12 @@
|
||||
//! Skills 命令层
|
||||
//!
|
||||
//! v3.10.0+ 统一管理架构:
|
||||
//! - 支持三应用开关(Claude/Codex/Gemini)
|
||||
//! - SSOT 存储在 ~/.cc-switch/skills/
|
||||
|
||||
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::format_skill_error;
|
||||
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
use crate::services::skill::SkillState;
|
||||
use crate::services::{Skill, SkillRepo, SkillService};
|
||||
use crate::store::AppState;
|
||||
use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
/// SkillService 状态包装
|
||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||
|
||||
/// 解析 app 参数为 AppType
|
||||
@@ -24,117 +19,65 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 统一管理命令 ==========
|
||||
|
||||
/// 获取所有已安装的 Skills
|
||||
#[tauri::command]
|
||||
pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {
|
||||
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
|
||||
/// 根据 app_type 生成带前缀的 skill key
|
||||
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
|
||||
let prefix = match app_type {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
};
|
||||
format!("{prefix}:{directory}")
|
||||
}
|
||||
|
||||
/// 安装 Skill(新版统一安装)
|
||||
///
|
||||
/// 参数:
|
||||
/// - skill: 从发现列表获取的技能信息
|
||||
/// - current_app: 当前选中的应用,安装后默认启用该应用
|
||||
#[tauri::command]
|
||||
pub async fn install_skill_unified(
|
||||
skill: DiscoverableSkill,
|
||||
current_app: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<InstalledSkill, String> {
|
||||
let app_type = parse_app_type(¤t_app)?;
|
||||
|
||||
service
|
||||
.0
|
||||
.install(&app_state.db, &skill, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 卸载 Skill(新版统一卸载)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 切换 Skill 的应用启用状态
|
||||
#[tauri::command]
|
||||
pub fn toggle_skill_app(
|
||||
id: String,
|
||||
app: String,
|
||||
enabled: bool,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 扫描未管理的 Skills
|
||||
#[tauri::command]
|
||||
pub fn scan_unmanaged_skills(
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<UnmanagedSkill>, String> {
|
||||
SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 从应用目录导入 Skills
|
||||
#[tauri::command]
|
||||
pub fn import_skills_from_apps(
|
||||
directories: Vec<String>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<InstalledSkill>, String> {
|
||||
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 发现功能命令 ==========
|
||||
|
||||
/// 发现可安装的 Skills(从仓库获取)
|
||||
#[tauri::command]
|
||||
pub async fn discover_available_skills(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<DiscoverableSkill>, String> {
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
service
|
||||
.0
|
||||
.discover_available(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 兼容旧 API 的命令 ==========
|
||||
|
||||
/// 获取技能列表(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn get_skills(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
service
|
||||
.0
|
||||
.list_skills(repos, &app_state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
get_skills_for_app("claude".to_string(), service, app_state).await
|
||||
}
|
||||
|
||||
/// 获取指定应用的技能列表(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn get_skills_for_app(
|
||||
app: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
// 新版本不再区分应用,统一返回所有技能
|
||||
let _ = parse_app_type(&app)?; // 验证 app 参数有效
|
||||
get_skills(service, app_state).await
|
||||
let app_type = parse_app_type(&app)?;
|
||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
|
||||
let skills = service
|
||||
.list_skills(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 自动同步本地已安装的 skills 到数据库
|
||||
// 这样用户在首次运行时,已有的 skills 会被自动记录
|
||||
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
||||
|
||||
for skill in &skills {
|
||||
if skill.installed {
|
||||
let key = get_skill_key(&app_type, &skill.directory);
|
||||
if !existing_states.contains_key(&key) {
|
||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
||||
if let Err(e) = app_state.db.update_skill_state(
|
||||
&key,
|
||||
&SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
) {
|
||||
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// 安装技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn install_skill(
|
||||
directory: String,
|
||||
@@ -144,34 +87,27 @@ pub async fn install_skill(
|
||||
install_skill_for_app("claude".to_string(), directory, service, app_state).await
|
||||
}
|
||||
|
||||
/// 安装指定应用的技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn install_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
// 先获取技能信息
|
||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
|
||||
let skills = service
|
||||
.0
|
||||
.discover_available(repos)
|
||||
.list_skills(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let skill = skills
|
||||
.into_iter()
|
||||
.find(|s| {
|
||||
let install_name = std::path::Path::new(&s.directory)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| s.directory.clone());
|
||||
install_name.eq_ignore_ascii_case(&directory)
|
||||
|| s.directory.eq_ignore_ascii_case(&directory)
|
||||
})
|
||||
.iter()
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||
.ok_or_else(|| {
|
||||
format_skill_error(
|
||||
"SKILL_NOT_FOUND",
|
||||
@@ -180,54 +116,103 @@ pub async fn install_skill_for_app(
|
||||
)
|
||||
})?;
|
||||
|
||||
service
|
||||
.0
|
||||
.install(&app_state.db, &skill, &app_type)
|
||||
.await
|
||||
if !skill.installed {
|
||||
let repo = SkillRepo {
|
||||
owner: skill.repo_owner.clone().ok_or_else(|| {
|
||||
format_skill_error(
|
||||
"MISSING_REPO_INFO",
|
||||
&[("directory", &directory), ("field", "owner")],
|
||||
None,
|
||||
)
|
||||
})?,
|
||||
name: skill.repo_name.clone().ok_or_else(|| {
|
||||
format_skill_error(
|
||||
"MISSING_REPO_INFO",
|
||||
&[("directory", &directory), ("field", "name")],
|
||||
None,
|
||||
)
|
||||
})?,
|
||||
branch: skill
|
||||
.repo_branch
|
||||
.clone()
|
||||
.unwrap_or_else(|| "main".to_string()),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
service
|
||||
.install_skill(directory.clone(), repo)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let key = get_skill_key(&app_type, &directory);
|
||||
app_state
|
||||
.db
|
||||
.update_skill_state(
|
||||
&key,
|
||||
&SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 卸载技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
uninstall_skill_for_app("claude".to_string(), directory, app_state)
|
||||
pub fn uninstall_skill(
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
|
||||
}
|
||||
|
||||
/// 卸载指定应用的技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let _ = parse_app_type(&app)?; // 验证参数
|
||||
let app_type = parse_app_type(&app)?;
|
||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
// 通过 directory 找到对应的 skill id
|
||||
let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;
|
||||
service
|
||||
.uninstall_skill(directory.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let skill = skills
|
||||
.into_iter()
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
|
||||
|
||||
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
|
||||
// Remove from database by setting installed = false
|
||||
let key = get_skill_key(&app_type, &directory);
|
||||
app_state
|
||||
.db
|
||||
.update_skill_state(
|
||||
&key,
|
||||
&SkillState {
|
||||
installed: false,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ========== 仓库管理命令 ==========
|
||||
|
||||
/// 获取技能仓库列表
|
||||
#[tauri::command]
|
||||
pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {
|
||||
pub fn get_skill_repos(
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<SkillRepo>, String> {
|
||||
app_state.db.get_skill_repos().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 添加技能仓库
|
||||
#[tauri::command]
|
||||
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
pub fn add_skill_repo(
|
||||
repo: SkillRepo,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
app_state
|
||||
.db
|
||||
.save_skill_repo(&repo)
|
||||
@@ -235,11 +220,11 @@ pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 删除技能仓库
|
||||
#[tauri::command]
|
||||
pub fn remove_skill_repo(
|
||||
owner: String,
|
||||
name: String,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
app_state
|
||||
|
||||
@@ -121,13 +121,13 @@ impl Database {
|
||||
enabled: false,
|
||||
auto_failover_enabled: false,
|
||||
max_retries: 3,
|
||||
streaming_first_byte_timeout: 60,
|
||||
streaming_idle_timeout: 120,
|
||||
non_streaming_timeout: 600,
|
||||
circuit_failure_threshold: 4,
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 300,
|
||||
circuit_failure_threshold: 5,
|
||||
circuit_success_threshold: 2,
|
||||
circuit_timeout_seconds: 60,
|
||||
circuit_error_rate_threshold: 0.6,
|
||||
circuit_error_rate_threshold: 0.5,
|
||||
circuit_min_requests: 10,
|
||||
})
|
||||
}
|
||||
@@ -210,12 +210,12 @@ impl Database {
|
||||
listen_address: row.get(0)?,
|
||||
listen_port: row.get::<_, i32>(1)? as u16,
|
||||
max_retries: row.get::<_, i32>(2)? as u8,
|
||||
request_timeout: 600, // 废弃字段,返回默认值
|
||||
request_timeout: 300, // 废弃字段,返回默认值
|
||||
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||
live_takeover_active: false, // 废弃字段
|
||||
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,
|
||||
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(120) as u64,
|
||||
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,
|
||||
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
|
||||
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
|
||||
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,156 +1,73 @@
|
||||
//! Skills 数据访问对象
|
||||
//!
|
||||
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
||||
//!
|
||||
//! v3.10.0+ 统一管理架构:
|
||||
//! - Skills 使用统一的 id 主键,支持三应用启用标志
|
||||
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
||||
|
||||
use crate::app_config::{InstalledSkill, SkillApps};
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::services::skill::SkillRepo;
|
||||
use crate::services::skill::{SkillRepo, SkillState};
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::params;
|
||||
|
||||
impl Database {
|
||||
// ========== InstalledSkill CRUD ==========
|
||||
|
||||
/// 获取所有已安装的 Skills
|
||||
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
|
||||
/// 获取所有 Skills 状态
|
||||
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let skill_iter = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(InstalledSkill {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
directory: row.get(3)?,
|
||||
repo_owner: row.get(4)?,
|
||||
repo_name: row.get(5)?,
|
||||
repo_branch: row.get(6)?,
|
||||
readme_url: row.get(7)?,
|
||||
apps: SkillApps {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
let directory: String = row.get(0)?;
|
||||
let app_type: String = row.get(1)?;
|
||||
let installed: bool = row.get(2)?;
|
||||
let installed_at_ts: i64 = row.get(3)?;
|
||||
|
||||
let installed_at =
|
||||
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
||||
|
||||
// 构建复合 key:"app_type:directory"
|
||||
let key = format!("{app_type}:{directory}");
|
||||
|
||||
Ok((
|
||||
key,
|
||||
SkillState {
|
||||
installed,
|
||||
installed_at,
|
||||
},
|
||||
installed_at: row.get(11)?,
|
||||
})
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut skills = IndexMap::new();
|
||||
for skill_res in skill_iter {
|
||||
let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
skills.insert(skill.id.clone(), skill);
|
||||
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
skills.insert(key, skill);
|
||||
}
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// 获取单个已安装的 Skill
|
||||
pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
/// 更新 Skill 状态
|
||||
/// key 格式为 "app_type:directory"
|
||||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
||||
// 解析 key
|
||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
||||
let (app, dir) = key.split_at(idx);
|
||||
(app, &dir[1..]) // 跳过冒号
|
||||
} else {
|
||||
// 向后兼容:如果没有前缀,默认为 claude
|
||||
("claude", key)
|
||||
};
|
||||
|
||||
let result = stmt.query_row([id], |row| {
|
||||
Ok(InstalledSkill {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
directory: row.get(3)?,
|
||||
repo_owner: row.get(4)?,
|
||||
repo_name: row.get(5)?,
|
||||
repo_branch: row.get(6)?,
|
||||
readme_url: row.get(7)?,
|
||||
apps: SkillApps {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
},
|
||||
installed_at: row.get(11)?,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(skill) => Ok(Some(skill)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存 Skill(添加或更新)
|
||||
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.directory,
|
||||
skill.repo_owner,
|
||||
skill.repo_name,
|
||||
skill.repo_branch,
|
||||
skill.readme_url,
|
||||
skill.apps.claude,
|
||||
skill.apps.codex,
|
||||
skill.apps.gemini,
|
||||
skill.installed_at,
|
||||
],
|
||||
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![directory, app_type, state.installed, state.installed_at.timestamp()],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除 Skill
|
||||
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute("DELETE FROM skills WHERE id = ?1", params![id])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
/// 清空所有 Skills(用于迁移)
|
||||
pub fn clear_skills(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute("DELETE FROM skills", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新 Skill 的应用启用状态
|
||||
pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
|
||||
params![apps.claude, apps.codex, apps.gemini, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
// ========== SkillRepo CRUD(保持原有) ==========
|
||||
|
||||
/// 获取所有 Skill 仓库
|
||||
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
@@ -184,8 +101,7 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -192,16 +192,13 @@ impl Database {
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
config: &MultiAppConfig,
|
||||
) -> Result<(), AppError> {
|
||||
// v3.10.0+:Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/)+ 数据库统一结构。
|
||||
//
|
||||
// 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据,
|
||||
// 且无法保证 SSOT 目录中一定存在对应的 skill 文件。
|
||||
//
|
||||
// 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。
|
||||
// 迁移后可通过:
|
||||
// - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)
|
||||
// - 或后续启动时的自动扫描逻辑
|
||||
// 来重建已安装技能记录。
|
||||
for (key, state) in &config.skills.skills {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||
params![key, state.installed, state.installed_at.timestamp()],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
|
||||
}
|
||||
|
||||
for repo in &config.skills.repos {
|
||||
tx.execute(
|
||||
|
||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 3;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 2;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -71,21 +71,11 @@ impl Database {
|
||||
PRIMARY KEY (id, app_type)
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 5. Skills 表(v3.10.0+ 统一结构)
|
||||
// 5. Skills 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
repo_owner TEXT,
|
||||
repo_name TEXT,
|
||||
repo_branch TEXT DEFAULT 'main',
|
||||
readme_url TEXT,
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -114,10 +104,10 @@ impl Database {
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||
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,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, 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.5,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -133,7 +123,7 @@ impl Database {
|
||||
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)",
|
||||
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -142,7 +132,7 @@ impl Database {
|
||||
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)",
|
||||
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -151,7 +141,7 @@ impl Database {
|
||||
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)",
|
||||
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -263,15 +253,15 @@ impl Database {
|
||||
|
||||
// 尝试添加超时配置列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
|
||||
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -341,11 +331,6 @@ impl Database {
|
||||
Self::migrate_v1_to_v2(conn)?;
|
||||
Self::set_user_version(conn, 2)?;
|
||||
}
|
||||
2 => {
|
||||
log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)");
|
||||
Self::migrate_v2_to_v3(conn)?;
|
||||
Self::set_user_version(conn, 3)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -482,19 +467,19 @@ impl Database {
|
||||
conn,
|
||||
"proxy_config",
|
||||
"streaming_first_byte_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 60",
|
||||
"INTEGER NOT NULL DEFAULT 30",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"streaming_idle_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 120",
|
||||
"INTEGER NOT NULL DEFAULT 60",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"non_streaming_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 600",
|
||||
"INTEGER NOT NULL DEFAULT 300",
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -666,10 +651,10 @@ impl Database {
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||
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,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, 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.5,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", [])?;
|
||||
@@ -704,17 +689,6 @@ impl Database {
|
||||
|
||||
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
|
||||
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
|
||||
// v3 结构(统一管理架构)已经是更高版本的 skills 表:
|
||||
// - 主键为 id
|
||||
// - 包含 enabled_claude / enabled_codex / enabled_gemini 等列
|
||||
// 在这种情况下,不应再执行 v1 -> v2 的迁移逻辑,否则会因列不匹配而失败。
|
||||
if Self::has_column(conn, "skills", "enabled_claude")?
|
||||
|| Self::has_column(conn, "skills", "id")?
|
||||
{
|
||||
log::info!("skills 表已经是 v3 结构,跳过 v1 -> v2 迁移");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 检查是否已经是新表结构
|
||||
if Self::has_column(conn, "skills", "app_type")? {
|
||||
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
|
||||
@@ -786,69 +760,6 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v2 -> v3 迁移:Skills 统一管理架构
|
||||
///
|
||||
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
||||
/// 支持三应用启用标志(enabled_claude, enabled_codex, enabled_gemini)。
|
||||
///
|
||||
/// 迁移策略:
|
||||
/// 1. 旧数据库只存储安装记录,真正的 skill 文件在文件系统
|
||||
/// 2. 直接重建新表结构,后续由 SkillService 在首次启动时扫描文件系统重建数据
|
||||
fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> {
|
||||
// 检查是否已经是新结构(通过检查是否有 enabled_claude 列)
|
||||
if Self::has_column(conn, "skills", "enabled_claude")? {
|
||||
log::info!("skills 表已经是 v3 结构,跳过迁移");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
|
||||
|
||||
// 1. 备份旧数据(用于日志)
|
||||
let old_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
log::info!("旧 skills 表有 {old_count} 条记录");
|
||||
|
||||
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
|
||||
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/,
|
||||
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
|
||||
let _ = conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
|
||||
[],
|
||||
);
|
||||
|
||||
// 2. 删除旧表
|
||||
conn.execute("DROP TABLE IF EXISTS skills", [])
|
||||
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
|
||||
|
||||
// 3. 创建新表
|
||||
conn.execute(
|
||||
"CREATE TABLE skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
repo_owner TEXT,
|
||||
repo_name TEXT,
|
||||
repo_branch TEXT DEFAULT 'main',
|
||||
readme_url TEXT,
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
|
||||
|
||||
log::info!(
|
||||
"skills 表已迁移到 v3 结构。\n\
|
||||
注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::*;
|
||||
use crate::app_config::MultiAppConfig;
|
||||
use crate::provider::{Provider, ProviderManager};
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::Connection;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -51,74 +51,6 @@ const LEGACY_SCHEMA_SQL: &str = r#"
|
||||
);
|
||||
"#;
|
||||
|
||||
// v3.8.x(schema v1)的真实表结构快照:用于验证从 v3.8.* 升级到当前版本的迁移链路
|
||||
// 参考:tag v3.8.3 的 src-tauri/src/database/schema.rs
|
||||
const V3_8_SCHEMA_V1_SQL: &str = r#"
|
||||
CREATE TABLE providers (
|
||||
id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
settings_config TEXT NOT NULL,
|
||||
website_url TEXT,
|
||||
category TEXT,
|
||||
created_at INTEGER,
|
||||
sort_index INTEGER,
|
||||
notes TEXT,
|
||||
icon TEXT,
|
||||
icon_color TEXT,
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id, app_type)
|
||||
);
|
||||
CREATE TABLE provider_endpoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
added_at INTEGER,
|
||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
description TEXT,
|
||||
homepage TEXT,
|
||||
docs TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE prompts (
|
||||
id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
PRIMARY KEY (id, app_type)
|
||||
);
|
||||
CREATE TABLE skills (
|
||||
key TEXT PRIMARY KEY,
|
||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE skill_repos (
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (owner, name)
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
"#;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ColumnInfo {
|
||||
r#type: String,
|
||||
@@ -314,124 +246,6 @@ fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
.expect("query by app_type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.expect("enable foreign keys");
|
||||
|
||||
// 模拟 v3.8.* 用户的数据库(schema v1)
|
||||
conn.execute_batch(V3_8_SCHEMA_V1_SQL)
|
||||
.expect("seed v3.8 schema v1");
|
||||
Database::set_user_version(&conn, 1).expect("set user_version=1");
|
||||
|
||||
// 插入一条旧版 Provider + Skill(用于验证迁移不会破坏既有数据)
|
||||
conn.execute(
|
||||
"INSERT INTO providers (
|
||||
id, app_type, name, settings_config, website_url, category,
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
"p1",
|
||||
"claude",
|
||||
"Test Provider",
|
||||
serde_json::to_string(&json!({ "anthropicApiKey": "sk-test" })).unwrap(),
|
||||
Option::<String>::None,
|
||||
Option::<String>::None,
|
||||
Option::<i64>::None,
|
||||
Option::<usize>::None,
|
||||
Option::<String>::None,
|
||||
Option::<String>::None,
|
||||
Option::<String>::None,
|
||||
"{}",
|
||||
1,
|
||||
],
|
||||
)
|
||||
.expect("seed provider");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||
params!["claude:demo-skill", 1, 1700000000i64],
|
||||
)
|
||||
.expect("seed legacy skill");
|
||||
|
||||
// 按应用启动流程:先 create_tables(补齐新增表),再 apply_schema_migrations(按 user_version 迁移)
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("user_version after migration"),
|
||||
SCHEMA_VERSION
|
||||
);
|
||||
|
||||
// v1 -> v2:providers 新增字段必须补齐
|
||||
for column in [
|
||||
"cost_multiplier",
|
||||
"limit_daily_usd",
|
||||
"limit_monthly_usd",
|
||||
"provider_type",
|
||||
"in_failover_queue",
|
||||
] {
|
||||
assert!(
|
||||
Database::has_column(&conn, "providers", column).expect("check column"),
|
||||
"providers.{column} should exist after migration"
|
||||
);
|
||||
}
|
||||
|
||||
// 旧 provider 不应丢失,且新增字段应有默认值
|
||||
let provider_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM providers WHERE id = 'p1' AND app_type = 'claude'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("count providers");
|
||||
assert_eq!(provider_count, 1);
|
||||
|
||||
let cost_multiplier: String = conn
|
||||
.query_row(
|
||||
"SELECT cost_multiplier FROM providers WHERE id = 'p1' AND app_type = 'claude'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("read cost_multiplier");
|
||||
assert_eq!(cost_multiplier, "1.0");
|
||||
|
||||
// v2 -> v3:skills 表重建为统一结构,并设置 pending 标记(后续由启动时扫描文件系统重建数据)
|
||||
assert!(
|
||||
Database::has_column(&conn, "skills", "enabled_claude").expect("check skills v3 column"),
|
||||
"skills table should be migrated to v3 structure"
|
||||
);
|
||||
let skills_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM skills", [], |r| r.get(0))
|
||||
.expect("count skills");
|
||||
assert_eq!(skills_count, 0, "skills table should be rebuilt empty");
|
||||
|
||||
let pending: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_pending'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(
|
||||
matches!(pending.as_deref(), Some("true") | Some("1")),
|
||||
"skills_ssot_migration_pending should be set after v2->v3 migration"
|
||||
);
|
||||
|
||||
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
|
||||
let proxy_rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||
.expect("count proxy_config rows");
|
||||
assert_eq!(proxy_rows, 3);
|
||||
|
||||
// model_pricing 应具备默认数据(迁移时会 seed)
|
||||
let pricing_rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |r| r.get(0))
|
||||
.expect("count model_pricing rows");
|
||||
assert!(pricing_rows > 0, "model_pricing should be seeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_does_not_write_to_disk() {
|
||||
// Create minimal valid config for migration
|
||||
|
||||
@@ -52,47 +52,6 @@ pub fn take_migration_success() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Skills SSOT 迁移结果状态
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SkillsMigrationPayload {
|
||||
pub count: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
|
||||
|
||||
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
|
||||
SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
|
||||
pub fn set_skills_migration_result(count: usize) {
|
||||
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||
*guard = Some(SkillsMigrationPayload { count, error: None });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_skills_migration_error(error: String) {
|
||||
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||
*guard = Some(SkillsMigrationPayload {
|
||||
count: 0,
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None)
|
||||
pub fn take_skills_migration_result() -> Option<SkillsMigrationPayload> {
|
||||
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||
guard.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+15
-160
@@ -273,25 +273,12 @@ pub fn run() {
|
||||
None
|
||||
};
|
||||
|
||||
// 现在创建数据库(包含 Schema 迁移)
|
||||
//
|
||||
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
|
||||
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
|
||||
// 否则表现可能只是“应用打不开/闪退”。
|
||||
let db = loop {
|
||||
match crate::database::Database::init() {
|
||||
Ok(db) => break Arc::new(db),
|
||||
Err(e) => {
|
||||
log::error!("Failed to init database: {e}");
|
||||
|
||||
if !show_database_init_error_dialog(app.handle(), &db_path, &e.to_string())
|
||||
{
|
||||
log::info!("用户选择退出程序");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
log::info!("用户选择重试初始化数据库");
|
||||
}
|
||||
// 现在创建数据库
|
||||
let db = match crate::database::Database::init() {
|
||||
Ok(db) => Arc::new(db),
|
||||
Err(e) => {
|
||||
log::error!("Failed to init database: {e}");
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -337,47 +324,6 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
|
||||
}
|
||||
|
||||
// 1.1. Skills 统一管理迁移:当数据库迁移到 v3 结构后,自动从各应用目录导入到 SSOT
|
||||
// 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。
|
||||
match app_state.db.get_setting("skills_ssot_migration_pending") {
|
||||
Ok(Some(flag)) if flag == "true" || flag == "1" => {
|
||||
// 安全保护:如果用户已经有 v3 结构的 Skills 数据,就不要自动清空重建。
|
||||
let has_existing = app_state
|
||||
.db
|
||||
.get_all_installed_skills()
|
||||
.map(|skills| !skills.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_existing {
|
||||
log::info!(
|
||||
"Detected skills_ssot_migration_pending but skills table not empty; skipping auto import."
|
||||
);
|
||||
let _ = app_state
|
||||
.db
|
||||
.set_setting("skills_ssot_migration_pending", "false");
|
||||
} else {
|
||||
match crate::services::skill::migrate_skills_to_ssot(&app_state.db) {
|
||||
Ok(count) => {
|
||||
log::info!("✓ Auto imported {count} skill(s) into SSOT");
|
||||
if count > 0 {
|
||||
crate::init_status::set_skills_migration_result(count);
|
||||
}
|
||||
let _ = app_state
|
||||
.db
|
||||
.set_setting("skills_ssot_migration_pending", "false");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("✗ Failed to auto import legacy skills to SSOT: {e}");
|
||||
crate::init_status::set_skills_migration_error(e.to_string());
|
||||
// 保留 pending 标志,方便下次启动重试
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // 未开启迁移标志,静默跳过
|
||||
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
||||
}
|
||||
|
||||
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
|
||||
for app in [
|
||||
crate::app_config::AppType::Claude,
|
||||
@@ -390,27 +336,6 @@ pub fn run() {
|
||||
) {
|
||||
Ok(true) => {
|
||||
log::info!("✓ Imported default provider for {}", app.as_str());
|
||||
|
||||
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
|
||||
if app_state
|
||||
.db
|
||||
.get_config_snippet(app.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
{
|
||||
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
|
||||
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
|
||||
} else {
|
||||
log::info!("✓ Extracted common config snippet for {}", app.as_str());
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
|
||||
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {} // 已有供应商,静默跳过
|
||||
Err(e) => {
|
||||
@@ -582,8 +507,14 @@ pub fn run() {
|
||||
app.manage(app_state);
|
||||
|
||||
// 初始化 SkillService
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
match SkillService::new() {
|
||||
Ok(skill_service) => {
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("初始化 SkillService 失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 异常退出恢复 + 代理状态自动恢复
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -633,14 +564,12 @@ pub fn run() {
|
||||
commands::open_external,
|
||||
commands::get_init_error,
|
||||
commands::get_migration_result,
|
||||
commands::get_skills_migration_result,
|
||||
commands::get_app_config_path,
|
||||
commands::open_app_config_folder,
|
||||
commands::get_claude_common_config_snippet,
|
||||
commands::set_claude_common_config_snippet,
|
||||
commands::get_common_config_snippet,
|
||||
commands::set_common_config_snippet,
|
||||
commands::extract_common_config_snippet,
|
||||
commands::read_live_provider_settings,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
@@ -672,7 +601,6 @@ pub fn run() {
|
||||
commands::upsert_mcp_server,
|
||||
commands::delete_mcp_server,
|
||||
commands::toggle_mcp_app,
|
||||
commands::import_mcp_from_apps,
|
||||
// Prompt management
|
||||
commands::get_prompts,
|
||||
commands::upsert_prompt,
|
||||
@@ -707,15 +635,7 @@ pub fn run() {
|
||||
commands::check_env_conflicts,
|
||||
commands::delete_env_vars,
|
||||
commands::restore_env_backup,
|
||||
// Skill management (v3.10.0+ unified)
|
||||
commands::get_installed_skills,
|
||||
commands::install_skill_unified,
|
||||
commands::uninstall_skill_unified,
|
||||
commands::toggle_skill_app,
|
||||
commands::scan_unmanaged_skills,
|
||||
commands::import_skills_from_apps,
|
||||
commands::discover_available_skills,
|
||||
// Skill management (legacy API compatibility)
|
||||
// Skill management
|
||||
commands::get_skills,
|
||||
commands::get_skills_for_app,
|
||||
commands::install_skill,
|
||||
@@ -1046,68 +966,3 @@ fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
|
||||
))
|
||||
.blocking_show()
|
||||
}
|
||||
|
||||
/// 显示数据库初始化/Schema 迁移失败对话框
|
||||
/// 返回 true 表示用户选择重试,false 表示用户选择退出
|
||||
fn show_database_init_error_dialog(
|
||||
app: &tauri::AppHandle,
|
||||
db_path: &std::path::Path,
|
||||
error: &str,
|
||||
) -> bool {
|
||||
let title = if is_chinese_locale() {
|
||||
"数据库初始化失败"
|
||||
} else {
|
||||
"Database Initialization Failed"
|
||||
};
|
||||
|
||||
let message = if is_chinese_locale() {
|
||||
format!(
|
||||
"初始化数据库或迁移数据库结构时发生错误:\n\n{error}\n\n\
|
||||
数据库文件路径:\n{db}\n\n\
|
||||
您的数据尚未丢失,应用不会自动删除数据库文件。\n\
|
||||
常见原因包括:数据库版本过新、文件损坏、权限不足、磁盘空间不足等。\n\n\
|
||||
建议:\n\
|
||||
1) 先备份整个配置目录(包含 cc-switch.db)\n\
|
||||
2) 如果提示“数据库版本过新”,请升级到更新版本\n\
|
||||
3) 如果刚升级出现异常,可回退旧版本导出/备份后再升级\n\n\
|
||||
点击「重试」重新尝试初始化\n\
|
||||
点击「退出」关闭程序",
|
||||
db = db_path.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"An error occurred while initializing or migrating the database:\n\n{error}\n\n\
|
||||
Database file path:\n{db}\n\n\
|
||||
Your data is NOT lost - the app will not delete the database automatically.\n\
|
||||
Common causes include: newer database version, corrupted file, permission issues, or low disk space.\n\n\
|
||||
Suggestions:\n\
|
||||
1) Back up the entire config directory (including cc-switch.db)\n\
|
||||
2) If you see “database version is newer”, please upgrade CC Switch\n\
|
||||
3) If this happened right after upgrading, consider rolling back to export/backup then upgrade again\n\n\
|
||||
Click 'Retry' to attempt initialization again\n\
|
||||
Click 'Exit' to close the program",
|
||||
db = db_path.display()
|
||||
)
|
||||
};
|
||||
|
||||
let retry_text = if is_chinese_locale() {
|
||||
"重试"
|
||||
} else {
|
||||
"Retry"
|
||||
};
|
||||
let exit_text = if is_chinese_locale() {
|
||||
"退出"
|
||||
} else {
|
||||
"Exit"
|
||||
};
|
||||
|
||||
app.dialog()
|
||||
.message(&message)
|
||||
.title(title)
|
||||
.kind(MessageDialogKind::Error)
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
retry_text.to_string(),
|
||||
exit_text.to_string(),
|
||||
))
|
||||
.blocking_show()
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@ pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -
|
||||
filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)
|
||||
}
|
||||
|
||||
/// 递归过滤实现
|
||||
#[cfg(test)]
|
||||
fn filter_recursive(value: Value, removed_keys: &mut Vec<String>) -> Value {
|
||||
filter_recursive_with_whitelist(value, removed_keys, &HashSet::new())
|
||||
}
|
||||
|
||||
/// 递归过滤实现(支持白名单)
|
||||
fn filter_recursive_with_whitelist(
|
||||
value: Value,
|
||||
|
||||
@@ -49,10 +49,10 @@ pub struct CircuitBreakerConfig {
|
||||
impl Default for CircuitBreakerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
failure_threshold: 4,
|
||||
failure_threshold: 5,
|
||||
success_threshold: 2,
|
||||
timeout_seconds: 60,
|
||||
error_rate_threshold: 0.6,
|
||||
error_rate_threshold: 0.5,
|
||||
min_requests: 10,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,17 +20,23 @@ use tokio::sync::RwLock;
|
||||
|
||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||
///
|
||||
/// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
|
||||
/// 参考成功透传的请求,保留更多原始 header
|
||||
/// 参考 Claude Code Hub 设计,过滤以下类别:
|
||||
/// 1. 认证类(会被覆盖)
|
||||
/// 2. 连接类(由 HTTP 客户端管理)
|
||||
/// 3. 代理转发类
|
||||
/// 4. CDN/云服务商特定头
|
||||
/// 5. 请求追踪类
|
||||
/// 6. 浏览器特定头(可能被上游检测)
|
||||
///
|
||||
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
|
||||
const HEADER_BLACKLIST: &[&str] = &[
|
||||
// 认证类(会被覆盖)
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
// 连接类(由 HTTP 客户端管理)
|
||||
// 连接类
|
||||
"host",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
// 编码类(会被覆盖为 identity)
|
||||
"accept-encoding",
|
||||
@@ -62,9 +68,16 @@ const HEADER_BLACKLIST: &[&str] = &[
|
||||
"x-b3-sampled",
|
||||
"traceparent",
|
||||
"tracestate",
|
||||
// anthropic 特定头单独处理,避免重复
|
||||
// 浏览器特定头(可能被上游检测为非 CLI 请求)
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-dest",
|
||||
"sec-ch-ua",
|
||||
"sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform",
|
||||
"accept-language",
|
||||
// anthropic-beta 单独处理,避免重复
|
||||
"anthropic-beta",
|
||||
"anthropic-version",
|
||||
// 客户端 IP 单独处理(默认透传)
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
@@ -542,30 +555,14 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 anthropic-beta Header(仅 Claude)
|
||||
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
|
||||
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
|
||||
if adapter.name() == "Claude" {
|
||||
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
|
||||
let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
// 检查是否已包含 claude-code-20250219
|
||||
if beta_str.contains(CLAUDE_CODE_BETA) {
|
||||
beta_str.to_string()
|
||||
} else {
|
||||
// 补充 claude-code-20250219
|
||||
format!("{CLAUDE_CODE_BETA},{beta_str}")
|
||||
}
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
}
|
||||
} else {
|
||||
// 如果客户端没有发送,使用默认值
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
};
|
||||
request = request.header("anthropic-beta", &beta_value);
|
||||
passed_headers.push(("anthropic-beta".to_string(), beta_value.clone()));
|
||||
log::info!("[{}] 设置 anthropic-beta: {}", adapter.name(), beta_value);
|
||||
// 处理 anthropic-beta Header(透传)
|
||||
// 参考 Claude Code Hub 的实现,直接透传客户端的 beta 标记
|
||||
if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
request = request.header("anthropic-beta", beta_str);
|
||||
passed_headers.push(("anthropic-beta".to_string(), beta_str.to_string()));
|
||||
log::info!("[{}] 透传 anthropic-beta: {}", adapter.name(), beta_str);
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端 IP 透传(默认开启)
|
||||
@@ -615,20 +612,19 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
|
||||
// 注意:只设置一次,避免重复
|
||||
if adapter.name() == "Claude" {
|
||||
let version_str = headers
|
||||
.get("anthropic-version")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("2023-06-01");
|
||||
request = request.header("anthropic-version", version_str);
|
||||
passed_headers.push(("anthropic-version".to_string(), version_str.to_string()));
|
||||
log::info!(
|
||||
"[{}] 设置 anthropic-version: {}",
|
||||
adapter.name(),
|
||||
version_str
|
||||
);
|
||||
// anthropic-version 透传:优先使用客户端的版本号
|
||||
// 参考 Claude Code Hub:透传客户端值而非固定版本
|
||||
if let Some(version) = headers.get("anthropic-version") {
|
||||
if let Ok(version_str) = version.to_str() {
|
||||
// 覆盖适配器设置的默认版本
|
||||
request = request.header("anthropic-version", version_str);
|
||||
passed_headers.push(("anthropic-version".to_string(), version_str.to_string()));
|
||||
log::info!(
|
||||
"[{}] 透传 anthropic-version: {}",
|
||||
adapter.name(),
|
||||
version_str
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 最终发送的 Headers 日志 ==========
|
||||
|
||||
@@ -54,7 +54,6 @@ impl ModelMapping {
|
||||
|| self.sonnet_model.is_some()
|
||||
|| self.opus_model.is_some()
|
||||
|| self.default_model.is_some()
|
||||
|| self.reasoning_model.is_some()
|
||||
}
|
||||
|
||||
/// 根据原始模型名称获取映射后的模型
|
||||
@@ -183,27 +182,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_provider_with_reasoning_only() -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
settings_config: json!({
|
||||
"env": {
|
||||
"ANTHROPIC_REASONING_MODEL": "reasoning-only-model"
|
||||
}
|
||||
}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sonnet_mapping() {
|
||||
let provider = create_provider_with_mapping();
|
||||
@@ -244,31 +222,6 @@ mod tests {
|
||||
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_only_mapping_in_thinking_mode() {
|
||||
let provider = create_provider_with_reasoning_only();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "enabled"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "reasoning-only-model");
|
||||
assert_eq!(mapped, Some("reasoning-only-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_only_mapping_does_not_affect_non_thinking() {
|
||||
let provider = create_provider_with_reasoning_only();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "disabled"}
|
||||
});
|
||||
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
|
||||
assert!(mapped.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_disabled() {
|
||||
let provider = create_provider_with_mapping();
|
||||
|
||||
@@ -217,37 +217,28 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
let base = format!(
|
||||
format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
endpoint.trim_start_matches('/')
|
||||
);
|
||||
|
||||
// 为 /v1/messages 端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
if endpoint.contains("/v1/messages") && !endpoint.contains("?") {
|
||||
format!("{base}?beta=true")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
|
||||
// 这里不再设置 anthropic-version,避免 header 重复
|
||||
match auth.strategy {
|
||||
// Anthropic 官方: Authorization Bearer + x-api-key
|
||||
// Anthropic 官方: Authorization Bearer + x-api-key + anthropic-version
|
||||
AuthStrategy::Anthropic => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key),
|
||||
.header("x-api-key", &auth.api_key)
|
||||
.header("anthropic-version", "2023-06-01"),
|
||||
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||||
AuthStrategy::ClaudeAuth => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
AuthStrategy::ClaudeAuth => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("anthropic-version", "2023-06-01"),
|
||||
// OpenRouter: Bearer
|
||||
AuthStrategy::Bearer => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
AuthStrategy::Bearer => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("anthropic-version", "2023-06-01"),
|
||||
_ => request,
|
||||
}
|
||||
}
|
||||
@@ -425,33 +416,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_other_endpoints() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 非 /v1/messages 端点不添加 ?beta=true
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_preserve_existing_query() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 已有查询参数时不重复添加
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,11 +28,11 @@ pub struct ProxyConfig {
|
||||
}
|
||||
|
||||
fn default_streaming_first_byte_timeout() -> u64 {
|
||||
60
|
||||
30
|
||||
}
|
||||
|
||||
fn default_streaming_idle_timeout() -> u64 {
|
||||
120
|
||||
60
|
||||
}
|
||||
|
||||
fn default_non_streaming_timeout() -> u64 {
|
||||
@@ -45,11 +45,11 @@ impl Default for ProxyConfig {
|
||||
listen_address: "127.0.0.1".to_string(),
|
||||
listen_port: 15721, // 使用较少占用的高位端口
|
||||
max_retries: 3,
|
||||
request_timeout: 600,
|
||||
request_timeout: 300,
|
||||
enable_logging: true,
|
||||
live_takeover_active: false,
|
||||
streaming_first_byte_timeout: 60,
|
||||
streaming_idle_timeout: 120,
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 600,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,8 +206,6 @@ impl McpService {
|
||||
// 调用原有的导入逻辑(从 mcp.rs)
|
||||
let count = crate::mcp::import_from_claude(&mut temp_config)?;
|
||||
|
||||
let mut new_count = 0;
|
||||
|
||||
// 如果有导入的服务器,保存到数据库
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
@@ -219,8 +217,6 @@ impl McpService {
|
||||
merged.apps.claude = true;
|
||||
merged
|
||||
} else {
|
||||
// 真正的新服务器
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
|
||||
@@ -233,7 +229,7 @@ impl McpService {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_count)
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 从 Codex 导入 MCP(v3.7.0 已更新为统一结构)
|
||||
@@ -244,8 +240,6 @@ impl McpService {
|
||||
// 调用原有的导入逻辑(从 mcp.rs)
|
||||
let count = crate::mcp::import_from_codex(&mut temp_config)?;
|
||||
|
||||
let mut new_count = 0;
|
||||
|
||||
// 如果有导入的服务器,保存到数据库
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
@@ -257,8 +251,6 @@ impl McpService {
|
||||
merged.apps.codex = true;
|
||||
merged
|
||||
} else {
|
||||
// 真正的新服务器
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
|
||||
@@ -271,7 +263,7 @@ impl McpService {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_count)
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 从 Gemini 导入 MCP(v3.7.0 已更新为统一结构)
|
||||
@@ -282,8 +274,6 @@ impl McpService {
|
||||
// 调用原有的导入逻辑(从 mcp.rs)
|
||||
let count = crate::mcp::import_from_gemini(&mut temp_config)?;
|
||||
|
||||
let mut new_count = 0;
|
||||
|
||||
// 如果有导入的服务器,保存到数据库
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
@@ -295,8 +285,6 @@ impl McpService {
|
||||
merged.apps.gemini = true;
|
||||
merged
|
||||
} else {
|
||||
// 真正的新服务器
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
|
||||
@@ -309,6 +297,6 @@ impl McpService {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_count)
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ pub use mcp::McpService;
|
||||
pub use prompt::PromptService;
|
||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||
pub use proxy::ProxyService;
|
||||
#[allow(unused_imports)]
|
||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
pub use skill::{Skill, SkillRepo, SkillService};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
#[allow(unused_imports)]
|
||||
pub use usage_stats::{
|
||||
|
||||
@@ -71,47 +71,6 @@ mod tests {
|
||||
assert_eq!(api_key, "token");
|
||||
assert_eq!(base_url, "https://claude.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
|
||||
let config_toml = r#"model_provider = "azure"
|
||||
model = "gpt-4"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.azure]
|
||||
name = "Azure OpenAI"
|
||||
base_url = "https://azure.example/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[mcp_servers.my_server]
|
||||
base_url = "http://localhost:8080"
|
||||
"#;
|
||||
|
||||
let settings = json!({ "config": config_toml });
|
||||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
||||
.expect("extract_codex_common_config should succeed");
|
||||
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model_provider")),
|
||||
"should remove top-level model_provider"
|
||||
);
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model =")),
|
||||
"should remove top-level model"
|
||||
);
|
||||
assert!(
|
||||
!extracted.contains("[model_providers"),
|
||||
"should remove entire model_providers table"
|
||||
);
|
||||
assert!(
|
||||
extracted.contains("http://localhost:8080"),
|
||||
"should keep mcp_servers.* base_url"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderService {
|
||||
@@ -292,14 +251,6 @@ impl ProviderService {
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
|
||||
// 关键修复:接管模式下切换供应商不会写回 Live 配置,
|
||||
// 需要主动清理 Claude Live 中的“模型覆盖”字段,避免仍以旧模型名发起请求。
|
||||
if matches!(app_type, AppType::Claude) {
|
||||
if let Err(e) = state.proxy_service.cleanup_claude_model_overrides_in_live() {
|
||||
log::warn!("清理 Claude Live 模型字段失败(不影响切换结果): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Note: No Live config write, no MCP sync
|
||||
// The proxy server will route requests to the new provider via is_current
|
||||
return Ok(());
|
||||
@@ -357,174 +308,6 @@ impl ProviderService {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
|
||||
pub fn extract_common_config_snippet(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<String, AppError> {
|
||||
// Get current provider
|
||||
let current_id = Self::current(state, app_type.clone())?;
|
||||
if current_id.is_empty() {
|
||||
return Err(AppError::Message("No current provider".to_string()));
|
||||
}
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers
|
||||
.get(¤t_id)
|
||||
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config snippet from a config value (e.g. editor content).
|
||||
pub fn extract_common_config_snippet_from_settings(
|
||||
app_type: AppType,
|
||||
settings_config: &Value,
|
||||
) -> Result<String, AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config for Claude (JSON format)
|
||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Fields to exclude from common config
|
||||
const ENV_EXCLUDES: &[&str] = &[
|
||||
// Auth
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
// Models (5 fields)
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
// Endpoint
|
||||
"ANTHROPIC_BASE_URL",
|
||||
];
|
||||
|
||||
const TOP_LEVEL_EXCLUDES: &[&str] = &[
|
||||
"apiBaseUrl",
|
||||
// Legacy model fields
|
||||
"primaryModel",
|
||||
"smallFastModel",
|
||||
];
|
||||
|
||||
// Remove env fields
|
||||
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
for key in ENV_EXCLUDES {
|
||||
env.remove(*key);
|
||||
}
|
||||
// If env is empty after removal, remove the env object itself
|
||||
if env.is_empty() {
|
||||
config.as_object_mut().map(|obj| obj.remove("env"));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove top-level fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
for key in TOP_LEVEL_EXCLUDES {
|
||||
obj.remove(*key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if result is empty
|
||||
if config.as_object().is_none_or(|obj| obj.is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for Codex (TOML format)
|
||||
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// Codex config is stored as { "auth": {...}, "config": "toml string" }
|
||||
let config_toml = settings
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if config_toml.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let mut doc = config_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
|
||||
|
||||
// Remove provider-specific fields.
|
||||
let root = doc.as_table_mut();
|
||||
root.remove("model");
|
||||
root.remove("model_provider");
|
||||
// Legacy/alt formats might use a top-level base_url.
|
||||
root.remove("base_url");
|
||||
|
||||
// Remove entire model_providers table (provider-specific configuration)
|
||||
root.remove("model_providers");
|
||||
|
||||
// Clean up multiple empty lines (keep at most one blank line).
|
||||
let mut cleaned = String::new();
|
||||
let mut blank_run = 0usize;
|
||||
for line in doc.to_string().lines() {
|
||||
if line.trim().is_empty() {
|
||||
blank_run += 1;
|
||||
if blank_run <= 1 {
|
||||
cleaned.push('\n');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
blank_run = 0;
|
||||
cleaned.push_str(line);
|
||||
cleaned.push('\n');
|
||||
}
|
||||
|
||||
Ok(cleaned.trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract common config for Gemini (JSON format)
|
||||
///
|
||||
/// Extracts `.env` values while excluding provider-specific credentials:
|
||||
/// - GOOGLE_GEMINI_BASE_URL
|
||||
/// - GEMINI_API_KEY
|
||||
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let env = settings.get("env").and_then(|v| v.as_object());
|
||||
|
||||
let mut snippet = serde_json::Map::new();
|
||||
if let Some(env) = env {
|
||||
for (key, value) in env {
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||
continue;
|
||||
}
|
||||
let Value::String(v) = value else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = v.trim();
|
||||
if !trimmed.is_empty() {
|
||||
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snippet.is_empty() {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&Value::Object(snippet))
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
///
|
||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||
|
||||
@@ -17,20 +17,6 @@ use tokio::sync::RwLock;
|
||||
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
|
||||
const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||||
|
||||
/// 代理接管模式下需要从 Claude Live 配置中移除的“模型覆盖”字段。
|
||||
///
|
||||
/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,
|
||||
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
|
||||
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
// Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyService {
|
||||
db: Arc<Database>,
|
||||
@@ -48,31 +34,6 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理接管模式下 Claude Live 配置中的模型覆盖字段。
|
||||
///
|
||||
/// 这可以避免“接管开启后切换供应商仍使用旧模型”的问题。
|
||||
/// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。
|
||||
pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {
|
||||
let mut config = self.read_claude_live()?;
|
||||
|
||||
let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
if env.remove(key).is_some() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
self.write_claude_live(&config)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置 AppHandle(在应用初始化时调用)
|
||||
pub fn set_app_handle(&self, handle: tauri::AppHandle) {
|
||||
futures::executor::block_on(async {
|
||||
@@ -779,10 +740,6 @@ impl ProxyService {
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
// 仅覆盖已存在的 Token 字段,避免新增字段导致用户困惑;
|
||||
// 若完全没有 Token 字段,则写入 ANTHROPIC_AUTH_TOKEN 占位符用于避免客户端警告。
|
||||
let token_keys = [
|
||||
@@ -863,10 +820,6 @@ impl ProxyService {
|
||||
let mut live_config = self.read_claude_live()?;
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
@@ -946,10 +899,6 @@ impl ProxyService {
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
|
||||
+281
-665
File diff suppressed because it is too large
Load Diff
+21
-105
@@ -13,8 +13,6 @@ import {
|
||||
Wrench,
|
||||
Server,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import type { Provider } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
@@ -44,7 +42,6 @@ import UsageScriptModal from "@/components/UsageScriptModal";
|
||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||
import { UniversalProviderPanel } from "@/components/universal";
|
||||
@@ -55,7 +52,6 @@ type View =
|
||||
| "settings"
|
||||
| "prompts"
|
||||
| "skills"
|
||||
| "skillsDiscovery"
|
||||
| "mcp"
|
||||
| "agents"
|
||||
| "universal";
|
||||
@@ -70,7 +66,6 @@ function App() {
|
||||
|
||||
const [activeApp, setActiveApp] = useState<AppId>("claude");
|
||||
const [currentView, setCurrentView] = useState<View>("providers");
|
||||
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||
@@ -86,7 +81,6 @@ function App() {
|
||||
const promptPanelRef = useRef<any>(null);
|
||||
const mcpPanelRef = useRef<any>(null);
|
||||
const skillsPageRef = useRef<any>(null);
|
||||
const unifiedSkillsPanelRef = useRef<any>(null);
|
||||
const addActionButtonClass =
|
||||
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
||||
|
||||
@@ -112,7 +106,8 @@ function App() {
|
||||
});
|
||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||
const currentProviderId = data?.currentProviderId ?? "";
|
||||
const hasSkillsSupport = true;
|
||||
// Skills 功能仅支持 Claude 和 Codex
|
||||
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
|
||||
|
||||
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
||||
const {
|
||||
@@ -223,35 +218,6 @@ function App() {
|
||||
checkMigration();
|
||||
}, [t]);
|
||||
|
||||
// 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT)
|
||||
useEffect(() => {
|
||||
const checkSkillsMigration = async () => {
|
||||
try {
|
||||
const result = await invoke<{ count: number; error?: string } | null>(
|
||||
"get_skills_migration_result",
|
||||
);
|
||||
if (result?.error) {
|
||||
toast.error(t("migration.skillsFailed"), {
|
||||
description: t("migration.skillsFailedDescription"),
|
||||
closeButton: true,
|
||||
});
|
||||
console.error("[App] Skills SSOT migration failed:", result.error);
|
||||
return;
|
||||
}
|
||||
if (result && result.count > 0) {
|
||||
toast.success(t("migration.skillsSuccess", { count: result.count }), {
|
||||
closeButton: true,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[App] Failed to check skills migration result:", error);
|
||||
}
|
||||
};
|
||||
|
||||
checkSkillsMigration();
|
||||
}, [t, queryClient]);
|
||||
|
||||
// 切换应用时检测当前应用的环境变量冲突
|
||||
useEffect(() => {
|
||||
const checkEnvOnSwitch = async () => {
|
||||
@@ -412,7 +378,6 @@ function App() {
|
||||
open={true}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
defaultTab={settingsDefaultTab}
|
||||
/>
|
||||
);
|
||||
case "prompts":
|
||||
@@ -426,13 +391,12 @@ function App() {
|
||||
);
|
||||
case "skills":
|
||||
return (
|
||||
<UnifiedSkillsPanel
|
||||
ref={unifiedSkillsPanelRef}
|
||||
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
onClose={() => setCurrentView("providers")}
|
||||
initialApp={activeApp}
|
||||
/>
|
||||
);
|
||||
case "skillsDiscovery":
|
||||
return <SkillsPage ref={skillsPageRef} initialApp={activeApp} />;
|
||||
case "mcp":
|
||||
return (
|
||||
<UnifiedMcpPanel
|
||||
@@ -568,13 +532,7 @@ function App() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setCurrentView(
|
||||
currentView === "skillsDiscovery"
|
||||
? "skills"
|
||||
: "providers",
|
||||
)
|
||||
}
|
||||
onClick={() => setCurrentView("providers")}
|
||||
className="mr-2 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
@@ -584,7 +542,6 @@ function App() {
|
||||
{currentView === "prompts" &&
|
||||
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
|
||||
{currentView === "skills" && t("skills.title")}
|
||||
{currentView === "skillsDiscovery" && t("skills.title")}
|
||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||
{currentView === "agents" && t("agents.title")}
|
||||
{currentView === "universal" &&
|
||||
@@ -612,20 +569,14 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setSettingsDefaultTab("general");
|
||||
setCurrentView("settings");
|
||||
}}
|
||||
onClick={() => setCurrentView("settings")}
|
||||
title={t("common.settings")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<UpdateBadge onClick={() => {
|
||||
setSettingsDefaultTab("about");
|
||||
setCurrentView("settings");
|
||||
}} />
|
||||
<UpdateBadge onClick={() => setCurrentView("settings")} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -636,60 +587,25 @@ function App() {
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
className={`ml-auto ${addActionButtonClass}`}
|
||||
title={t("prompts.add")}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("prompts.add")}
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "mcp" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("mcp.importExisting")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("mcp.addMcp")}
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className={`ml-auto ${addActionButtonClass}`}
|
||||
title={t("mcp.unifiedPanel.addServer")}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("skills.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skillsDiscovery")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
{t("skills.discover")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skillsDiscovery" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -41,7 +41,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
||||
>
|
||||
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">
|
||||
{t("settings.updateBadge")}
|
||||
v{updateInfo.availableVersion}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -12,9 +12,6 @@ interface FullScreenPanelProps {
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DRAG_BAR_HEIGHT = 28; // px - match App.tsx
|
||||
const HEADER_HEIGHT = 64; // px - match App.tsx
|
||||
|
||||
/**
|
||||
* Reusable full-screen panel component
|
||||
* Handles portal rendering, header with back button, and footer
|
||||
@@ -47,47 +44,22 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
className="fixed inset-0 z-[60] flex flex-col"
|
||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||
>
|
||||
{/* Drag region - match App.tsx */}
|
||||
{/* Header */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: "drag",
|
||||
height: DRAG_BAR_HEIGHT,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Header - match App.tsx */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center"
|
||||
data-tauri-drag-region
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: "drag",
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
height: HEADER_HEIGHT,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="flex-shrink-0 py-3 border-b border-border-default"
|
||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||
>
|
||||
<div
|
||||
className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<div className="h-4 w-full" data-tauri-drag-region />
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="rounded-lg select-none"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold text-foreground select-none">
|
||||
{title}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,16 +3,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { Server } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useAllMcpServers,
|
||||
useToggleMcpApp,
|
||||
useDeleteMcpServer,
|
||||
useImportMcpFromApps,
|
||||
} from "@/hooks/useMcp";
|
||||
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
|
||||
import type { McpServer } from "@/types";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import McpFormModal from "./McpFormModal";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
import { useDeleteMcpServer } from "@/hooks/useMcp";
|
||||
import { Edit3, Trash2 } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { mcpPresets } from "@/config/mcpPresets";
|
||||
@@ -28,7 +24,6 @@ interface UnifiedMcpPanelProps {
|
||||
*/
|
||||
export interface UnifiedMcpPanelHandle {
|
||||
openAdd: () => void;
|
||||
openImport: () => void;
|
||||
}
|
||||
|
||||
const UnifiedMcpPanel = React.forwardRef<
|
||||
@@ -49,7 +44,6 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
const { data: serversMap, isLoading } = useAllMcpServers();
|
||||
const toggleAppMutation = useToggleMcpApp();
|
||||
const deleteServerMutation = useDeleteMcpServer();
|
||||
const importMutation = useImportMcpFromApps();
|
||||
|
||||
// Convert serversMap to array for easier rendering
|
||||
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
||||
@@ -92,28 +86,8 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const count = await importMutation.mutateAsync();
|
||||
if (count === 0) {
|
||||
toast.success(t("mcp.unifiedPanel.noImportFound"), {
|
||||
closeButton: true,
|
||||
});
|
||||
} else {
|
||||
toast.success(t("mcp.unifiedPanel.importSuccess", { count }), {
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openAdd: handleAdd,
|
||||
openImport: handleImport,
|
||||
}));
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
|
||||
@@ -68,7 +68,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||
}, [initialData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
if (!name.trim() || !content.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
disabled={!name.trim() || !content.trim() || saving}
|
||||
>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -60,7 +60,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
}, [initialData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
if (!name.trim() || !content.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
disabled={!name.trim() || !content.trim() || saving}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface PromptToggleProps {
|
||||
|
||||
/**
|
||||
* Toggle 开关组件(提示词专用)
|
||||
* 启用时为绿色,禁用时为灰色
|
||||
* 启用时为蓝色,禁用时为灰色
|
||||
*/
|
||||
const PromptToggle: React.FC<PromptToggleProps> = ({
|
||||
enabled,
|
||||
@@ -23,8 +23,8 @@ const PromptToggle: React.FC<PromptToggleProps> = ({
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!enabled)}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
|
||||
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
||||
${enabled ? "bg-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -222,7 +222,9 @@ export function ClaudeFormFields({
|
||||
{/* 推理模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="reasoningModel">
|
||||
{t("providerForm.anthropicReasoningModel")}
|
||||
{t("providerForm.anthropicReasoningModel", {
|
||||
defaultValue: "推理模型 (Thinking)",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="reasoningModel"
|
||||
@@ -231,6 +233,9 @@ export function ClaudeFormFields({
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
|
||||
}
|
||||
placeholder={t("providerForm.reasoningModelPlaceholder", {
|
||||
defaultValue: "",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Save, Download, Loader2 } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -11,8 +11,6 @@ interface CodexCommonConfigModalProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,8 +23,6 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
@@ -53,24 +49,6 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{onExtract && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
{t("codexConfig.extractFromCurrent", {
|
||||
defaultValue: "从编辑内容提取",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -26,10 +26,6 @@ interface CodexConfigEditorProps {
|
||||
authError: string;
|
||||
|
||||
configError: string; // config.toml 错误提示
|
||||
|
||||
onExtract?: () => void;
|
||||
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
@@ -45,8 +41,6 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
commonConfigError,
|
||||
authError,
|
||||
configError,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
@@ -85,8 +79,6 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
error={commonConfigError}
|
||||
onExtract={onExtract}
|
||||
isExtracting={isExtracting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, Download, Loader2 } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface CommonConfigEditorProps {
|
||||
@@ -17,8 +17,6 @@ interface CommonConfigEditorProps {
|
||||
onEditClick: () => void;
|
||||
isModalOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
export function CommonConfigEditor({
|
||||
@@ -32,8 +30,6 @@ export function CommonConfigEditor({
|
||||
onEditClick,
|
||||
isModalOpen,
|
||||
onModalClose,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
}: CommonConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
@@ -115,24 +111,6 @@ export function CommonConfigEditor({
|
||||
onClose={onModalClose}
|
||||
footer={
|
||||
<>
|
||||
{onExtract && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
{t("claudeConfig.extractFromCurrent", {
|
||||
defaultValue: "从编辑内容提取",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onModalClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Save, Download, Loader2 } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -11,17 +11,15 @@ interface GeminiCommonConfigModalProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
||||
* Allows editing of common env snippet shared across Gemini providers
|
||||
* Allows editing of common JSON configuration shared across Gemini providers
|
||||
*/
|
||||
export const GeminiCommonConfigModal: React.FC<
|
||||
GeminiCommonConfigModalProps
|
||||
> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => {
|
||||
> = ({ isOpen, onClose, value, onChange, error }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
@@ -49,24 +47,6 @@ export const GeminiCommonConfigModal: React.FC<
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{onExtract && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
{t("geminiConfig.extractFromCurrent", {
|
||||
defaultValue: "从编辑内容提取",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@@ -81,7 +61,7 @@ export const GeminiCommonConfigModal: React.FC<
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("geminiConfig.commonConfigHint", {
|
||||
defaultValue:
|
||||
"该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
||||
"通用配置片段将合并到所有启用它的 Gemini 供应商配置中",
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -89,7 +69,9 @@ export const GeminiCommonConfigModal: React.FC<
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`{
|
||||
"GEMINI_MODEL": "gemini-3-pro-preview"
|
||||
"timeout": 30000,
|
||||
"maxRetries": 3,
|
||||
"customField": "value"
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
|
||||
@@ -15,8 +15,6 @@ interface GeminiConfigEditorProps {
|
||||
commonConfigError: string;
|
||||
envError: string;
|
||||
configError: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
@@ -32,8 +30,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
commonConfigError,
|
||||
envError,
|
||||
configError,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
@@ -52,16 +48,16 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
onChange={onEnvChange}
|
||||
onBlur={onEnvBlur}
|
||||
error={envError}
|
||||
useCommonConfig={useCommonConfig}
|
||||
onCommonConfigToggle={onCommonConfigToggle}
|
||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||
commonConfigError={commonConfigError}
|
||||
/>
|
||||
|
||||
{/* Config JSON Section */}
|
||||
<GeminiConfigSection
|
||||
value={configValue}
|
||||
onChange={onConfigChange}
|
||||
useCommonConfig={useCommonConfig}
|
||||
onCommonConfigToggle={onCommonConfigToggle}
|
||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||
commonConfigError={commonConfigError}
|
||||
configError={configError}
|
||||
/>
|
||||
|
||||
@@ -72,8 +68,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
error={commonConfigError}
|
||||
onExtract={onExtract}
|
||||
isExtracting={isExtracting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,6 @@ interface GeminiEnvSectionProps {
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
onEditCommonConfig: () => void;
|
||||
commonConfigError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,10 +17,6 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
onEditCommonConfig,
|
||||
commonConfigError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
@@ -51,14 +43,92 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="geminiEnv"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||
</label>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
||||
GEMINI_API_KEY=sk-your-api-key-here
|
||||
GEMINI_MODEL=gemini-3-pro-preview`}
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("geminiConfig.envFileHint", {
|
||||
defaultValue: "使用 .env 格式配置 Gemini 环境变量",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GeminiConfigSectionProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
onEditCommonConfig: () => void;
|
||||
commonConfigError?: string;
|
||||
configError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeminiConfigSection - Config JSON editor section with common config support
|
||||
*/
|
||||
export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
onEditCommonConfig,
|
||||
commonConfigError,
|
||||
configError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="geminiEnv"
|
||||
htmlFor="geminiConfig"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||
{t("geminiConfig.configJson", {
|
||||
defaultValue: "配置文件 (config.json)",
|
||||
})}
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
@@ -92,76 +162,6 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
||||
GEMINI_API_KEY=sk-your-api-key-here
|
||||
GEMINI_MODEL=gemini-3-pro-preview`}
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("geminiConfig.envFileHint", {
|
||||
defaultValue: "使用 .env 格式配置 Gemini 环境变量",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GeminiConfigSectionProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
configError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeminiConfigSection - Config JSON editor section with common config support
|
||||
*/
|
||||
export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
configError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="geminiConfig"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("geminiConfig.configJson", {
|
||||
defaultValue: "配置文件 (config.json)",
|
||||
})}
|
||||
</label>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -352,13 +352,10 @@ export function ProviderForm({
|
||||
commonConfigError,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
isExtracting: isClaudeExtracting,
|
||||
handleExtract: handleClaudeExtract,
|
||||
} = useCommonConfigSnippet({
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
initialData: appId === "claude" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
|
||||
@@ -368,13 +365,10 @@ export function ProviderForm({
|
||||
commonConfigError: codexCommonConfigError,
|
||||
handleCommonConfigToggle: handleCodexCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
|
||||
isExtracting: isCodexExtracting,
|
||||
handleExtract: handleCodexExtract,
|
||||
} = useCodexCommonConfig({
|
||||
codexConfig,
|
||||
onConfigChange: handleCodexConfigChange,
|
||||
initialData: appId === "codex" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
// 使用 Gemini 配置 hook (仅 Gemini 模式)
|
||||
@@ -393,7 +387,6 @@ export function ProviderForm({
|
||||
handleGeminiConfigChange,
|
||||
resetGeminiConfig,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
} = useGeminiConfigState({
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
});
|
||||
@@ -454,15 +447,10 @@ export function ProviderForm({
|
||||
commonConfigError: geminiCommonConfigError,
|
||||
handleCommonConfigToggle: handleGeminiCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
|
||||
isExtracting: isGeminiExtracting,
|
||||
handleExtract: handleGeminiExtract,
|
||||
} = useGeminiCommonConfig({
|
||||
envValue: geminiEnv,
|
||||
onEnvChange: handleGeminiEnvChange,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
configValue: geminiConfig,
|
||||
onConfigChange: handleGeminiConfigChange,
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
@@ -939,8 +927,6 @@ export function ProviderForm({
|
||||
commonConfigError={codexCommonConfigError}
|
||||
authError={codexAuthError}
|
||||
configError={codexConfigError}
|
||||
onExtract={handleCodexExtract}
|
||||
isExtracting={isCodexExtracting}
|
||||
/>
|
||||
{/* 配置验证错误显示 */}
|
||||
<FormField
|
||||
@@ -969,8 +955,6 @@ export function ProviderForm({
|
||||
commonConfigError={geminiCommonConfigError}
|
||||
envError={envError}
|
||||
configError={geminiConfigError}
|
||||
onExtract={handleGeminiExtract}
|
||||
isExtracting={isGeminiExtracting}
|
||||
/>
|
||||
{/* 配置验证错误显示 */}
|
||||
<FormField
|
||||
@@ -996,8 +980,6 @@ export function ProviderForm({
|
||||
onEditClick={() => setIsCommonConfigModalOpen(true)}
|
||||
isModalOpen={isCommonConfigModalOpen}
|
||||
onModalClose={() => setIsCommonConfigModalOpen(false)}
|
||||
onExtract={handleClaudeExtract}
|
||||
isExtracting={isClaudeExtracting}
|
||||
/>
|
||||
{/* 配置验证错误显示 */}
|
||||
<FormField
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
updateTomlCommonConfigSnippet,
|
||||
hasTomlCommonConfigSnippet,
|
||||
@@ -16,7 +15,6 @@ interface UseCodexCommonConfigProps {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
selectedPresetId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,26 +25,16 @@ export function useCodexCommonConfig({
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
}: UseCodexCommonConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
|
||||
// 用于跟踪是否正在通过通用配置更新
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
@@ -112,44 +100,6 @@ export function useCodexCommonConfig({
|
||||
}
|
||||
}, [initialData, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
// 仅新建模式、加载完成、尚未初始化过
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
// 检查 TOML 片段是否有实质内容(不只是注释和空行)
|
||||
const lines = commonConfigSnippet.split("\n");
|
||||
const hasContent = lines.some((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed && !trimmed.startsWith("#");
|
||||
});
|
||||
|
||||
if (hasContent) {
|
||||
setUseCommonConfig(true);
|
||||
// 合并通用配置到当前配置
|
||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (!error) {
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
initialData,
|
||||
commonConfigSnippet,
|
||||
isLoading,
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
]);
|
||||
|
||||
// 处理通用配置开关
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
@@ -190,9 +140,7 @@ export function useCodexCommonConfig({
|
||||
// 保存到 config.json(清空)
|
||||
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
|
||||
console.error("保存 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
setCommonConfigError(`保存失败: ${error}`);
|
||||
});
|
||||
|
||||
if (useCommonConfig) {
|
||||
@@ -212,9 +160,7 @@ export function useCodexCommonConfig({
|
||||
// 保存到 config.json
|
||||
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
|
||||
console.error("保存 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
setCommonConfigError(`保存失败: ${error}`);
|
||||
});
|
||||
|
||||
// 若当前启用通用配置,需要替换为最新片段
|
||||
@@ -263,46 +209,12 @@ export function useCodexCommonConfig({
|
||||
setUseCommonConfig(hasCommon);
|
||||
}, [codexConfig, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 从编辑器当前内容提取通用配置片段
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("codex", {
|
||||
settingsConfig: JSON.stringify({
|
||||
config: codexConfig ?? "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!extracted || !extracted.trim()) {
|
||||
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态
|
||||
setCommonConfigSnippetState(extracted);
|
||||
|
||||
// 保存到后端
|
||||
await configApi.setCommonConfigSnippet("codex", extracted);
|
||||
} catch (error) {
|
||||
console.error("提取 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.extractFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}, [codexConfig, t]);
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
updateCommonConfigSnippet,
|
||||
hasCommonConfigSnippet,
|
||||
@@ -18,7 +17,6 @@ interface UseCommonConfigSnippetProps {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
selectedPresetId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,26 +27,16 @@ export function useCommonConfigSnippet({
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
}: UseCommonConfigSnippetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||
DEFAULT_COMMON_CONFIG_SNIPPET,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
|
||||
// 用于跟踪是否正在通过通用配置更新
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
@@ -114,44 +102,6 @@ export function useCommonConfigSnippet({
|
||||
}
|
||||
}, [initialData, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
// 仅新建模式、加载完成、尚未初始化过
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
// 检查片段是否有实质内容
|
||||
try {
|
||||
const snippetObj = JSON.parse(commonConfigSnippet);
|
||||
const hasContent = Object.keys(snippetObj).length > 0;
|
||||
if (hasContent) {
|
||||
setUseCommonConfig(true);
|
||||
// 合并通用配置到当前配置
|
||||
const { updatedConfig, error } = updateCommonConfigSnippet(
|
||||
settingsConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (!error) {
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
}, [
|
||||
initialData,
|
||||
commonConfigSnippet,
|
||||
isLoading,
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
]);
|
||||
|
||||
// 处理通用配置开关
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
@@ -191,9 +141,7 @@ export function useCommonConfigSnippet({
|
||||
// 保存到 config.json(清空)
|
||||
configApi.setCommonConfigSnippet("claude", "").catch((error) => {
|
||||
console.error("保存通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
setCommonConfigError(`保存失败: ${error}`);
|
||||
});
|
||||
|
||||
if (useCommonConfig) {
|
||||
@@ -217,9 +165,7 @@ export function useCommonConfigSnippet({
|
||||
// 保存到 config.json
|
||||
configApi.setCommonConfigSnippet("claude", value).catch((error) => {
|
||||
console.error("保存通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
setCommonConfigError(`保存失败: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,51 +215,12 @@ export function useCommonConfigSnippet({
|
||||
setUseCommonConfig(hasCommon);
|
||||
}, [settingsConfig, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 从编辑器当前内容提取通用配置片段
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("claude", {
|
||||
settingsConfig,
|
||||
});
|
||||
|
||||
if (!extracted || extracted === "{}") {
|
||||
setCommonConfigError(t("claudeConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
const validationError = validateJsonConfig(extracted, "提取的配置");
|
||||
if (validationError) {
|
||||
setCommonConfigError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态
|
||||
setCommonConfigSnippetState(extracted);
|
||||
|
||||
// 保存到后端
|
||||
await configApi.setCommonConfigSnippet("claude", extracted);
|
||||
} catch (error) {
|
||||
console.error("提取通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.extractFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}, [settingsConfig, t]);
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,154 +1,126 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { configApi } from "@/lib/api";
|
||||
|
||||
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
||||
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
||||
|
||||
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
||||
"GOOGLE_GEMINI_BASE_URL",
|
||||
"GEMINI_API_KEY",
|
||||
] as const;
|
||||
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
||||
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = `{
|
||||
"timeout": 30000,
|
||||
"maxRetries": 3
|
||||
}`;
|
||||
|
||||
interface UseGeminiCommonConfigProps {
|
||||
envValue: string;
|
||||
onEnvChange: (env: string) => void;
|
||||
envStringToObj: (envString: string) => Record<string, string>;
|
||||
envObjToString: (envObj: Record<string, unknown>) => string;
|
||||
configValue: string;
|
||||
onConfigChange: (config: string) => void;
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
selectedPresetId?: string;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
/**
|
||||
* 深度合并两个对象(用于合并通用配置)
|
||||
*/
|
||||
function deepMerge(target: any, source: any): any {
|
||||
if (typeof target !== "object" || target === null) {
|
||||
return source;
|
||||
}
|
||||
if (typeof source !== "object" || source === null) {
|
||||
return target;
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
if (typeof source[key] === "object" && !Array.isArray(source[key])) {
|
||||
result[key] = deepMerge(result[key], source[key]);
|
||||
} else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置中移除通用配置片段(递归比较)
|
||||
*/
|
||||
function removeCommonConfig(config: any, commonConfig: any): any {
|
||||
if (typeof config !== "object" || config === null) {
|
||||
return config;
|
||||
}
|
||||
if (typeof commonConfig !== "object" || commonConfig === null) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const result = { ...config };
|
||||
for (const key of Object.keys(commonConfig)) {
|
||||
if (result[key] === undefined) continue;
|
||||
|
||||
// 如果值完全相等,删除该键
|
||||
if (JSON.stringify(result[key]) === JSON.stringify(commonConfig[key])) {
|
||||
delete result[key];
|
||||
} else if (
|
||||
typeof result[key] === "object" &&
|
||||
!Array.isArray(result[key]) &&
|
||||
typeof commonConfig[key] === "object" &&
|
||||
!Array.isArray(commonConfig[key])
|
||||
) {
|
||||
// 递归移除嵌套对象
|
||||
result[key] = removeCommonConfig(result[key], commonConfig[key]);
|
||||
// 如果移除后对象为空,删除该键
|
||||
if (Object.keys(result[key]).length === 0) {
|
||||
delete result[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查配置中是否包含通用配置片段
|
||||
*/
|
||||
function hasCommonConfigSnippet(config: any, commonConfig: any): boolean {
|
||||
if (typeof config !== "object" || config === null) return false;
|
||||
if (typeof commonConfig !== "object" || commonConfig === null) return false;
|
||||
|
||||
for (const key of Object.keys(commonConfig)) {
|
||||
if (config[key] === undefined) return false;
|
||||
if (JSON.stringify(config[key]) !== JSON.stringify(commonConfig[key])) {
|
||||
// 检查嵌套对象
|
||||
if (
|
||||
typeof config[key] === "object" &&
|
||||
!Array.isArray(config[key]) &&
|
||||
typeof commonConfig[key] === "object" &&
|
||||
!Array.isArray(commonConfig[key])
|
||||
) {
|
||||
if (!hasCommonConfigSnippet(config[key], commonConfig[key])) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Gemini 通用配置片段 (JSON 格式)
|
||||
* 写入 Gemini 的 .env,但会排除以下敏感字段:
|
||||
* - GOOGLE_GEMINI_BASE_URL
|
||||
* - GEMINI_API_KEY
|
||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
||||
*/
|
||||
export function useGeminiCommonConfig({
|
||||
envValue,
|
||||
onEnvChange,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
configValue,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
}: UseGeminiCommonConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
|
||||
// 用于跟踪是否正在通过通用配置更新
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
|
||||
const parseSnippetEnv = useCallback(
|
||||
(
|
||||
snippetString: string,
|
||||
): { env: Record<string, string>; error?: string } => {
|
||||
const trimmed = snippetString.trim();
|
||||
if (!trimmed) {
|
||||
return { env: {} };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed)) {
|
||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
const forbiddenKeys = keys.filter((key) =>
|
||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
|
||||
);
|
||||
if (forbiddenKeys.length > 0) {
|
||||
return {
|
||||
env: {},
|
||||
error: t("geminiConfig.commonConfigInvalidKeys", {
|
||||
keys: forbiddenKeys.join(", "),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value !== "string") {
|
||||
return {
|
||||
env: {},
|
||||
error: t("geminiConfig.commonConfigInvalidValues"),
|
||||
};
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!normalized) continue;
|
||||
env[key] = normalized;
|
||||
}
|
||||
|
||||
return { env };
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const hasEnvCommonConfigSnippet = useCallback(
|
||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
||||
const entries = Object.entries(snippetEnv);
|
||||
if (entries.length === 0) return false;
|
||||
return entries.every(([key, value]) => envObj[key] === value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const applySnippetToEnv = useCallback(
|
||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
||||
const updated = { ...envObj };
|
||||
for (const [key, value] of Object.entries(snippetEnv)) {
|
||||
if (typeof value === "string") {
|
||||
updated[key] = value;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeSnippetFromEnv = useCallback(
|
||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
||||
const updated = { ...envObj };
|
||||
for (const [key, value] of Object.entries(snippetEnv)) {
|
||||
if (typeof value === "string" && updated[key] === value) {
|
||||
delete updated[key];
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
@@ -170,13 +142,6 @@ export function useGeminiCommonConfig({
|
||||
const legacySnippet =
|
||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (legacySnippet && legacySnippet.trim()) {
|
||||
const parsed = parseSnippetEnv(legacySnippet);
|
||||
if (parsed.error) {
|
||||
console.warn(
|
||||
"[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 迁移到 config.json
|
||||
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
|
||||
if (mounted) {
|
||||
@@ -207,110 +172,60 @@ export function useGeminiCommonConfig({
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [parseSnippetEnv]);
|
||||
}, []);
|
||||
|
||||
// 初始化时检查通用配置片段(编辑模式)
|
||||
useEffect(() => {
|
||||
if (initialData?.settingsConfig && !isLoading) {
|
||||
try {
|
||||
const env =
|
||||
isPlainObject(initialData.settingsConfig.env) &&
|
||||
Object.keys(initialData.settingsConfig.env).length > 0
|
||||
? (initialData.settingsConfig.env as Record<string, string>)
|
||||
const config =
|
||||
typeof initialData.settingsConfig.config === "object"
|
||||
? initialData.settingsConfig.config
|
||||
: {};
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) return;
|
||||
const hasCommon = hasEnvCommonConfigSnippet(
|
||||
env,
|
||||
parsed.env as Record<string, string>,
|
||||
);
|
||||
const commonConfigObj = JSON.parse(commonConfigSnippet);
|
||||
const hasCommon = hasCommonConfigSnippet(config, commonConfigObj);
|
||||
setUseCommonConfig(hasCommon);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
}, [
|
||||
commonConfigSnippet,
|
||||
hasEnvCommonConfigSnippet,
|
||||
initialData,
|
||||
isLoading,
|
||||
parseSnippetEnv,
|
||||
]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
// 仅新建模式、加载完成、尚未初始化过
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) return;
|
||||
const hasContent = Object.keys(parsed.env).length > 0;
|
||||
if (!hasContent) return;
|
||||
|
||||
setUseCommonConfig(true);
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
const merged = applySnippetToEnv(currentEnv, parsed.env);
|
||||
const nextEnvString = envObjToString(merged);
|
||||
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onEnvChange(nextEnvString);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}, [
|
||||
initialData,
|
||||
isLoading,
|
||||
commonConfigSnippet,
|
||||
envValue,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
applySnippetToEnv,
|
||||
onEnvChange,
|
||||
parseSnippetEnv,
|
||||
]);
|
||||
}, [initialData, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 处理通用配置开关
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) {
|
||||
setCommonConfigError(parsed.error);
|
||||
try {
|
||||
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
|
||||
const commonConfigObj = JSON.parse(commonConfigSnippet);
|
||||
|
||||
let updatedConfig: any;
|
||||
if (checked) {
|
||||
// 合并通用配置
|
||||
updatedConfig = deepMerge(configObj, commonConfigObj);
|
||||
} else {
|
||||
// 移除通用配置
|
||||
updatedConfig = removeCommonConfig(configObj, commonConfigObj);
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(checked);
|
||||
|
||||
// 标记正在通过通用配置更新
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(JSON.stringify(updatedConfig, null, 2));
|
||||
|
||||
// 在下一个事件循环中重置标记
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setCommonConfigError(`配置合并失败: ${errorMessage}`);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
if (Object.keys(parsed.env).length === 0) {
|
||||
setCommonConfigError(t("geminiConfig.noCommonConfigToApply"));
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
const updatedEnvObj = checked
|
||||
? applySnippetToEnv(currentEnv, parsed.env)
|
||||
: removeSnippetFromEnv(currentEnv, parsed.env);
|
||||
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(checked);
|
||||
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onEnvChange(envObjToString(updatedEnvObj));
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
},
|
||||
[
|
||||
applySnippetToEnv,
|
||||
commonConfigSnippet,
|
||||
envObjToString,
|
||||
envStringToObj,
|
||||
envValue,
|
||||
onEnvChange,
|
||||
parseSnippetEnv,
|
||||
removeSnippetFromEnv,
|
||||
t,
|
||||
],
|
||||
[configValue, commonConfigSnippet, onConfigChange],
|
||||
);
|
||||
|
||||
// 处理通用配置片段变化
|
||||
@@ -324,142 +239,95 @@ export function useGeminiCommonConfig({
|
||||
// 保存到 config.json(清空)
|
||||
configApi.setCommonConfigSnippet("gemini", "").catch((error) => {
|
||||
console.error("保存 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
setCommonConfigError(`保存失败: ${error}`);
|
||||
});
|
||||
|
||||
if (useCommonConfig) {
|
||||
const parsed = parseSnippetEnv(previousSnippet);
|
||||
if (!parsed.error && Object.keys(parsed.env).length > 0) {
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env);
|
||||
onEnvChange(envObjToString(updatedEnv));
|
||||
// 移除旧的通用配置
|
||||
try {
|
||||
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
|
||||
const previousCommonConfigObj = JSON.parse(previousSnippet);
|
||||
const updatedConfig = removeCommonConfig(
|
||||
configObj,
|
||||
previousCommonConfigObj,
|
||||
);
|
||||
onConfigChange(JSON.stringify(updatedConfig, null, 2));
|
||||
setUseCommonConfig(false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setUseCommonConfig(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验 JSON 格式
|
||||
const parsed = parseSnippetEnv(value);
|
||||
if (parsed.error) {
|
||||
setCommonConfigError(parsed.error);
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json
|
||||
configApi.setCommonConfigSnippet("gemini", value).catch((error) => {
|
||||
console.error("保存 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(`保存失败: ${error}`);
|
||||
});
|
||||
} catch {
|
||||
setCommonConfigError("通用配置片段格式错误(必须是有效的 JSON)");
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
configApi.setCommonConfigSnippet("gemini", value).catch((error) => {
|
||||
console.error("保存 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
// 若当前启用通用配置,需要替换为最新片段
|
||||
if (useCommonConfig) {
|
||||
const prevParsed = parseSnippetEnv(previousSnippet);
|
||||
const prevEnv = prevParsed.error ? {} : prevParsed.env;
|
||||
const nextEnv = parsed.env;
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
try {
|
||||
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
|
||||
const previousCommonConfigObj = JSON.parse(previousSnippet);
|
||||
const newCommonConfigObj = JSON.parse(value);
|
||||
|
||||
const withoutOld =
|
||||
Object.keys(prevEnv).length > 0
|
||||
? removeSnippetFromEnv(currentEnv, prevEnv)
|
||||
: currentEnv;
|
||||
const withNew =
|
||||
Object.keys(nextEnv).length > 0
|
||||
? applySnippetToEnv(withoutOld, nextEnv)
|
||||
: withoutOld;
|
||||
// 先移除旧的通用配置
|
||||
const withoutOld = removeCommonConfig(
|
||||
configObj,
|
||||
previousCommonConfigObj,
|
||||
);
|
||||
// 再合并新的通用配置
|
||||
const withNew = deepMerge(withoutOld, newCommonConfigObj);
|
||||
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onEnvChange(envObjToString(withNew));
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
// 标记正在通过通用配置更新,避免触发状态检查
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(JSON.stringify(withNew, null, 2));
|
||||
|
||||
// 在下一个事件循环中重置标记
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setCommonConfigError(`配置替换失败: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
applySnippetToEnv,
|
||||
commonConfigSnippet,
|
||||
envObjToString,
|
||||
envStringToObj,
|
||||
envValue,
|
||||
onEnvChange,
|
||||
parseSnippetEnv,
|
||||
removeSnippetFromEnv,
|
||||
t,
|
||||
useCommonConfig,
|
||||
],
|
||||
[commonConfigSnippet, configValue, useCommonConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||
useEffect(() => {
|
||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) return;
|
||||
const envObj = envStringToObj(envValue);
|
||||
setUseCommonConfig(
|
||||
hasEnvCommonConfigSnippet(envObj, parsed.env as Record<string, string>),
|
||||
);
|
||||
}, [
|
||||
envValue,
|
||||
commonConfigSnippet,
|
||||
envStringToObj,
|
||||
hasEnvCommonConfigSnippet,
|
||||
isLoading,
|
||||
parseSnippetEnv,
|
||||
]);
|
||||
|
||||
// 从编辑器当前内容提取通用配置片段
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
|
||||
settingsConfig: JSON.stringify({
|
||||
env: envStringToObj(envValue),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!extracted || extracted === "{}") {
|
||||
setCommonConfigError(t("geminiConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
const parsed = parseSnippetEnv(extracted);
|
||||
if (parsed.error) {
|
||||
setCommonConfigError(t("geminiConfig.extractedConfigInvalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态
|
||||
setCommonConfigSnippetState(extracted);
|
||||
|
||||
// 保存到后端
|
||||
await configApi.setCommonConfigSnippet("gemini", extracted);
|
||||
} catch (error) {
|
||||
console.error("提取 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.extractFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
|
||||
const commonConfigObj = JSON.parse(commonConfigSnippet);
|
||||
const hasCommon = hasCommonConfigSnippet(configObj, commonConfigObj);
|
||||
setUseCommonConfig(hasCommon);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}, [envStringToObj, envValue, parseSnippetEnv, t]);
|
||||
}, [configValue, commonConfigSnippet, isLoading]);
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ 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">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{["claude", "codex", "gemini"].map((toolName, index) => {
|
||||
const tool = toolVersions.find((item) => item.name === toolName);
|
||||
const displayName = tool?.name ?? toolName;
|
||||
|
||||
@@ -52,14 +52,12 @@ interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImportSuccess?: () => void | Promise<void>;
|
||||
defaultTab?: string;
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImportSuccess,
|
||||
defaultTab = "general",
|
||||
}: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -100,10 +98,10 @@ export function SettingsPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab(defaultTab);
|
||||
setActiveTab("general");
|
||||
resetStatus();
|
||||
}
|
||||
}, [open, resetStatus, defaultTab]);
|
||||
}, [open, resetStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresRestart) {
|
||||
@@ -333,6 +331,28 @@ export function SettingsPage({
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<ModelTestConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="failover"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
@@ -451,28 +471,6 @@ export function SettingsPage({
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<ModelTestConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="pricing"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
|
||||
@@ -12,13 +12,13 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||||
import type { Skill, SkillRepo } from "@/lib/api/skills";
|
||||
|
||||
interface RepoManagerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
repos: SkillRepo[];
|
||||
skills: DiscoverableSkill[];
|
||||
skills: Skill[];
|
||||
onAdd: (repo: SkillRepo) => Promise<void>;
|
||||
onRemove: (owner: string, name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import { Label } from "@/components/ui/label";
|
||||
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||||
import type { Skill, SkillRepo } from "@/lib/api/skills";
|
||||
|
||||
interface RepoManagerPanelProps {
|
||||
repos: SkillRepo[];
|
||||
skills: DiscoverableSkill[];
|
||||
skills: Skill[];
|
||||
onAdd: (repo: SkillRepo) => Promise<void>;
|
||||
onRemove: (owner: string, name: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
@@ -92,7 +92,7 @@ export function RepoManagerPanel({
|
||||
{/* 添加仓库表单 */}
|
||||
<div className="space-y-4 glass-card rounded-xl p-6">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("skills.addRepo")}
|
||||
添加技能仓库
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -12,12 +12,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import type { DiscoverableSkill } from "@/lib/api/skills";
|
||||
|
||||
type SkillCardSkill = DiscoverableSkill & { installed: boolean };
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillCardSkill;
|
||||
skill: Skill;
|
||||
onInstall: (directory: string) => Promise<void>;
|
||||
onUninstall: (directory: string) => Promise<void>;
|
||||
}
|
||||
@@ -59,7 +57,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
||||
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:shadow-lg group relative overflow-hidden">
|
||||
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useState, useMemo, forwardRef, useImperativeHandle } from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -14,18 +20,15 @@ import { toast } from "sonner";
|
||||
import { SkillCard } from "./SkillCard";
|
||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
import {
|
||||
useDiscoverableSkills,
|
||||
useInstalledSkills,
|
||||
useInstallSkill,
|
||||
useSkillRepos,
|
||||
useAddSkillRepo,
|
||||
useRemoveSkillRepo,
|
||||
skillsApi,
|
||||
type Skill,
|
||||
type SkillRepo,
|
||||
type AppType,
|
||||
} from "@/hooks/useSkills";
|
||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||||
} from "@/lib/api/skills";
|
||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||
|
||||
interface SkillsPageProps {
|
||||
onClose?: () => void;
|
||||
initialApp?: AppType;
|
||||
}
|
||||
|
||||
@@ -34,138 +37,163 @@ export interface SkillsPageHandle {
|
||||
openRepoManager: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills 发现面板
|
||||
* 用于浏览和安装来自仓库的 Skills
|
||||
*/
|
||||
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
({ initialApp = "claude" }, ref) => {
|
||||
({ onClose: _onClose, initialApp = "claude" }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<
|
||||
"all" | "installed" | "uninstalled"
|
||||
>("all");
|
||||
// 使用 initialApp,不允许切换
|
||||
const selectedApp = initialApp;
|
||||
|
||||
// currentApp 用于安装时的默认应用
|
||||
const currentApp = initialApp;
|
||||
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await skillsApi.getAll(selectedApp);
|
||||
setSkills(data);
|
||||
if (afterLoad) {
|
||||
afterLoad(data);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Queries
|
||||
const {
|
||||
data: discoverableSkills,
|
||||
isLoading: loadingDiscoverable,
|
||||
isFetching: fetchingDiscoverable,
|
||||
refetch: refetchDiscoverable,
|
||||
} = useDiscoverableSkills();
|
||||
const { data: installedSkills } = useInstalledSkills();
|
||||
const { data: repos = [], refetch: refetchRepos } = useSkillRepos();
|
||||
// 传入 "skills.loadFailed" 作为标题
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.loadFailed",
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const installMutation = useInstallSkill();
|
||||
const addRepoMutation = useAddSkillRepo();
|
||||
const removeRepoMutation = useRemoveSkillRepo();
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 8000,
|
||||
});
|
||||
|
||||
// 已安装的 directory 集合
|
||||
const installedDirs = useMemo(() => {
|
||||
if (!installedSkills) return new Set<string>();
|
||||
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
|
||||
}, [installedSkills]);
|
||||
console.error("Load skills failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await skillsApi.getRepos();
|
||||
setRepos(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load repos:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 为发现列表补齐 installed 状态,供 SkillCard 使用
|
||||
const skills: DiscoverableSkillItem[] = useMemo(() => {
|
||||
if (!discoverableSkills) return [];
|
||||
return discoverableSkills.map((d) => {
|
||||
const installName =
|
||||
d.directory.split("/").pop()?.toLowerCase() ||
|
||||
d.directory.toLowerCase();
|
||||
return {
|
||||
...d,
|
||||
installed: installedDirs.has(installName),
|
||||
};
|
||||
});
|
||||
}, [discoverableSkills, installedDirs]);
|
||||
|
||||
const loading = loadingDiscoverable || fetchingDiscoverable;
|
||||
useEffect(() => {
|
||||
Promise.all([loadSkills(), loadRepos()]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: () => {
|
||||
refetchDiscoverable();
|
||||
refetchRepos();
|
||||
},
|
||||
refresh: () => loadSkills(),
|
||||
openRepoManager: () => setRepoManagerOpen(true),
|
||||
}));
|
||||
|
||||
const handleInstall = async (directory: string) => {
|
||||
// 找到对应的 DiscoverableSkill
|
||||
const skill = discoverableSkills?.find(
|
||||
(s) =>
|
||||
s.directory === directory ||
|
||||
s.directory.split("/").pop() === directory,
|
||||
);
|
||||
if (!skill) {
|
||||
toast.error(t("skills.notFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await installMutation.mutateAsync({
|
||||
skill,
|
||||
currentApp,
|
||||
});
|
||||
toast.success(t("skills.installSuccess", { name: skill.name }), {
|
||||
await skillsApi.install(directory, selectedApp);
|
||||
toast.success(t("skills.installSuccess", { name: directory }), {
|
||||
closeButton: true,
|
||||
});
|
||||
await loadSkills();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 使用错误解析器格式化错误,传入 "skills.installFailed"
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.installFailed",
|
||||
);
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 10000, // 延长显示时间让用户看清
|
||||
});
|
||||
|
||||
console.error("Install skill failed:", {
|
||||
directory,
|
||||
error,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (directory: string) => {
|
||||
try {
|
||||
await skillsApi.uninstall(directory, selectedApp);
|
||||
toast.success(t("skills.uninstallSuccess", { name: directory }), {
|
||||
closeButton: true,
|
||||
});
|
||||
await loadSkills();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 使用错误解析器格式化错误,传入 "skills.uninstallFailed"
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.uninstallFailed",
|
||||
);
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 10000,
|
||||
});
|
||||
console.error("Install skill failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (_directory: string) => {
|
||||
// 在发现面板中,不支持卸载,需要在主面板中操作
|
||||
toast.info(t("skills.uninstallInMainPanel"));
|
||||
console.error("Uninstall skill failed:", {
|
||||
directory,
|
||||
error,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRepo = async (repo: SkillRepo) => {
|
||||
try {
|
||||
await addRepoMutation.mutateAsync(repo);
|
||||
toast.success(
|
||||
t("skills.repo.addSuccess", {
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
await skillsApi.addRepo(repo);
|
||||
|
||||
let repoSkillCount = 0;
|
||||
await Promise.all([
|
||||
loadRepos(),
|
||||
loadSkills((data) => {
|
||||
repoSkillCount = data.filter(
|
||||
(skill) =>
|
||||
skill.repoOwner === repo.owner &&
|
||||
skill.repoName === repo.name &&
|
||||
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||
).length;
|
||||
}),
|
||||
]);
|
||||
|
||||
toast.success(
|
||||
t("skills.repo.addSuccess", {
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
count: repoSkillCount,
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveRepo = async (owner: string, name: string) => {
|
||||
try {
|
||||
await removeRepoMutation.mutateAsync({ owner, name });
|
||||
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
await skillsApi.removeRepo(owner, name);
|
||||
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
|
||||
closeButton: true,
|
||||
});
|
||||
await Promise.all([loadRepos(), loadSkills()]);
|
||||
};
|
||||
|
||||
// 过滤技能列表
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, Trash2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useInstalledSkills,
|
||||
useToggleSkillApp,
|
||||
useUninstallSkill,
|
||||
useScanUnmanagedSkills,
|
||||
useImportSkillsFromApps,
|
||||
type InstalledSkill,
|
||||
type AppType,
|
||||
} from "@/hooks/useSkills";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UnifiedSkillsPanelProps {
|
||||
onOpenDiscovery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 Skills 管理面板
|
||||
* v3.10.0 新架构:所有 Skills 统一管理,每个 Skill 通过开关控制应用到哪些客户端
|
||||
*/
|
||||
export interface UnifiedSkillsPanelHandle {
|
||||
openDiscovery: () => void;
|
||||
openImport: () => void;
|
||||
}
|
||||
|
||||
const UnifiedSkillsPanel = React.forwardRef<
|
||||
UnifiedSkillsPanelHandle,
|
||||
UnifiedSkillsPanelProps
|
||||
>(({ onOpenDiscovery }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
|
||||
// Queries and Mutations
|
||||
const { data: skills, isLoading } = useInstalledSkills();
|
||||
const toggleAppMutation = useToggleSkillApp();
|
||||
const uninstallMutation = useUninstallSkill();
|
||||
const { data: unmanagedSkills, refetch: scanUnmanaged } =
|
||||
useScanUnmanagedSkills();
|
||||
const importMutation = useImportSkillsFromApps();
|
||||
|
||||
// Count enabled skills per app
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0 };
|
||||
if (!skills) return counts;
|
||||
skills.forEach((skill) => {
|
||||
if (skill.apps.claude) counts.claude++;
|
||||
if (skill.apps.codex) counts.codex++;
|
||||
if (skill.apps.gemini) counts.gemini++;
|
||||
});
|
||||
return counts;
|
||||
}, [skills]);
|
||||
|
||||
const handleToggleApp = async (
|
||||
id: string,
|
||||
app: AppType,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
try {
|
||||
await toggleAppMutation.mutateAsync({ id, app, enabled });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = (skill: InstalledSkill) => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: t("skills.uninstall"),
|
||||
message: t("skills.uninstallConfirm", { name: skill.name }),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await uninstallMutation.mutateAsync(skill.id);
|
||||
setConfirmDialog(null);
|
||||
toast.success(t("skills.uninstallSuccess", { name: skill.name }), {
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenImport = async () => {
|
||||
try {
|
||||
const result = await scanUnmanaged();
|
||||
if (!result.data || result.data.length === 0) {
|
||||
toast.success(t("skills.noUnmanagedFound"), { closeButton: true });
|
||||
return;
|
||||
}
|
||||
setImportDialogOpen(true);
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (directories: string[]) => {
|
||||
try {
|
||||
const imported = await importMutation.mutateAsync(directories);
|
||||
setImportDialogOpen(false);
|
||||
toast.success(t("skills.importSuccess", { count: imported.length }), {
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openDiscovery: onOpenDiscovery,
|
||||
openImport: handleOpenImport,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
|
||||
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||
{t("skills.apps.gemini")}: {enabledCounts.gemini}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t("skills.loading")}
|
||||
</div>
|
||||
) : !skills || skills.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||
<Sparkles size={24} className="text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t("skills.noInstalled")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("skills.noInstalledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{skills.map((skill) => (
|
||||
<InstalledSkillListItem
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onToggleApp={handleToggleApp}
|
||||
onUninstall={() => handleUninstall(skill)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Import Dialog */}
|
||||
{importDialogOpen && unmanagedSkills && (
|
||||
<ImportSkillsDialog
|
||||
skills={unmanagedSkills}
|
||||
onImport={handleImport}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel";
|
||||
|
||||
/**
|
||||
* 已安装 Skill 列表项组件
|
||||
*/
|
||||
interface InstalledSkillListItemProps {
|
||||
skill: InstalledSkill;
|
||||
onToggleApp: (id: string, app: AppType, enabled: boolean) => void;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
skill,
|
||||
onToggleApp,
|
||||
onUninstall,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const openDocs = async () => {
|
||||
if (!skill.readmeUrl) return;
|
||||
try {
|
||||
await settingsApi.openExternal(skill.readmeUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 生成来源标签
|
||||
const sourceLabel = useMemo(() => {
|
||||
if (skill.repoOwner && skill.repoName) {
|
||||
return `${skill.repoOwner}/${skill.repoName}`;
|
||||
}
|
||||
return t("skills.local");
|
||||
}, [skill.repoOwner, skill.repoName, t]);
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
|
||||
{/* 左侧:Skill 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground">{skill.name}</h3>
|
||||
{skill.readmeUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
className="h-6 px-2"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">{sourceLabel}</p>
|
||||
</div>
|
||||
|
||||
{/* 中间:应用开关 */}
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-claude`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.claude")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-claude`}
|
||||
checked={skill.apps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "claude", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-codex`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.codex")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-codex`}
|
||||
checked={skill.apps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "codex", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-gemini`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.gemini")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-gemini`}
|
||||
checked={skill.apps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "gemini", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:删除按钮 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onUninstall}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
title={t("skills.uninstall")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入 Skills 对话框
|
||||
*/
|
||||
interface ImportSkillsDialogProps {
|
||||
skills: Array<{
|
||||
directory: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
foundIn: string[];
|
||||
}>;
|
||||
onImport: (directories: string[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
skills,
|
||||
onImport,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
new Set(skills.map((s) => s.directory)),
|
||||
);
|
||||
|
||||
const toggleSelect = (directory: string) => {
|
||||
const newSelected = new Set(selected);
|
||||
if (newSelected.has(directory)) {
|
||||
newSelected.delete(directory);
|
||||
} else {
|
||||
newSelected.add(directory);
|
||||
}
|
||||
setSelected(newSelected);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
onImport(Array.from(selected));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-background rounded-xl p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-2">{t("skills.import")}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("skills.importDescription")}
|
||||
</p>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
|
||||
{skills.map((skill) => (
|
||||
<label
|
||||
key={skill.directory}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(skill.directory)}
|
||||
onChange={() => toggleSelect(skill.directory)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={selected.size === 0}>
|
||||
{t("skills.importSelected", { count: selected.size })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedSkillsPanel;
|
||||
@@ -87,10 +87,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
|
||||
ANTHROPIC_MODEL: "glm-4.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -107,10 +107,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
|
||||
ANTHROPIC_MODEL: "glm-4.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -124,7 +124,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
websiteUrl: "https://bailian.console.aliyun.com",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
ANTHROPIC_BASE_URL:
|
||||
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "qwen3-max",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3-max",
|
||||
@@ -177,10 +178,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-4.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-4.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.7",
|
||||
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-4.6",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.6",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -242,10 +243,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_MODEL: "MiniMax-M2",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -268,10 +269,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_MODEL: "MiniMax-M2",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
|
||||
@@ -85,7 +85,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
isOfficial: true,
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: `model_provider = "azure"
|
||||
model = "gpt-5.2"
|
||||
model = "gpt-5.1-codex"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
|
||||
@@ -113,7 +113,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"aihubmix",
|
||||
"https://aihubmix.com/v1",
|
||||
"gpt-5.2",
|
||||
"gpt-5.1-codex",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://aihubmix.com/v1",
|
||||
@@ -128,7 +128,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"dmxapi",
|
||||
"https://www.dmxapi.cn/v1",
|
||||
"gpt-5.2",
|
||||
"gpt-5.1-codex",
|
||||
),
|
||||
endpointCandidates: ["https://www.dmxapi.cn/v1"],
|
||||
isPartner: true, // 合作伙伴
|
||||
@@ -143,7 +143,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"packycode",
|
||||
"https://www.packyapi.com/v1",
|
||||
"gpt-5.2",
|
||||
"gpt-5.1-codex",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://www.packyapi.com/v1",
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface CodexTemplate {
|
||||
*/
|
||||
export function getCodexCustomTemplate(): CodexTemplate {
|
||||
const config = `model_provider = "custom"
|
||||
model = "gpt-5.2"
|
||||
model = "gpt-5-codex"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
|
||||
|
||||
@@ -59,16 +59,3 @@ export function useDeleteMcpServer() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从所有应用导入 MCP 服务器
|
||||
*/
|
||||
export function useImportMcpFromApps() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => mcpApi.importFromApps(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
skillsApi,
|
||||
type AppType,
|
||||
type DiscoverableSkill,
|
||||
type InstalledSkill,
|
||||
} from "@/lib/api/skills";
|
||||
|
||||
/**
|
||||
* 查询所有已安装的 Skills
|
||||
*/
|
||||
export function useInstalledSkills() {
|
||||
return useQuery({
|
||||
queryKey: ["skills", "installed"],
|
||||
queryFn: () => skillsApi.getInstalled(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发现可安装的 Skills(从仓库获取)
|
||||
*/
|
||||
export function useDiscoverableSkills() {
|
||||
return useQuery({
|
||||
queryKey: ["skills", "discoverable"],
|
||||
queryFn: () => skillsApi.discoverAvailable(),
|
||||
staleTime: Infinity, // 无限缓存,直到仓库变化时 invalidate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 Skill
|
||||
*/
|
||||
export function useInstallSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skill,
|
||||
currentApp,
|
||||
}: {
|
||||
skill: DiscoverableSkill;
|
||||
currentApp: AppType;
|
||||
}) => skillsApi.installUnified(skill, currentApp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载 Skill
|
||||
*/
|
||||
export function useUninstallSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => skillsApi.uninstallUnified(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 Skill 在特定应用的启用状态
|
||||
*/
|
||||
export function useToggleSkillApp() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
app,
|
||||
enabled,
|
||||
}: {
|
||||
id: string;
|
||||
app: AppType;
|
||||
enabled: boolean;
|
||||
}) => skillsApi.toggleApp(id, app, enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描未管理的 Skills
|
||||
*/
|
||||
export function useScanUnmanagedSkills() {
|
||||
return useQuery({
|
||||
queryKey: ["skills", "unmanaged"],
|
||||
queryFn: () => skillsApi.scanUnmanaged(),
|
||||
enabled: false, // 手动触发
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从应用目录导入 Skills
|
||||
*/
|
||||
export function useImportSkillsFromApps() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (directories: string[]) =>
|
||||
skillsApi.importFromApps(directories),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仓库列表
|
||||
*/
|
||||
export function useSkillRepos() {
|
||||
return useQuery({
|
||||
queryKey: ["skills", "repos"],
|
||||
queryFn: () => skillsApi.getRepos(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加仓库
|
||||
*/
|
||||
export function useAddSkillRepo() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: skillsApi.addRepo,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除仓库
|
||||
*/
|
||||
export function useRemoveSkillRepo() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ owner, name }: { owner: string; name: string }) =>
|
||||
skillsApi.removeRepo(owner, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 辅助类型 ==========
|
||||
|
||||
export type { InstalledSkill, DiscoverableSkill, AppType };
|
||||
@@ -53,11 +53,7 @@
|
||||
"editCommonConfig": "Edit Common Config",
|
||||
"editCommonConfigTitle": "Edit Common Config Snippet",
|
||||
"commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked",
|
||||
"fullSettingsHint": "Full Claude Code settings.json content",
|
||||
"extractFromCurrent": "Extract from Editor",
|
||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
||||
"extractFailed": "Extract failed: {{error}}",
|
||||
"saveFailed": "Save failed: {{error}}"
|
||||
"fullSettingsHint": "Full Claude Code settings.json content"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "View on GitHub",
|
||||
@@ -251,7 +247,6 @@
|
||||
"aboutHint": "View version information and update status.",
|
||||
"portableMode": "Portable mode: updates require manual download.",
|
||||
"updateAvailable": "New version available: {{version}}",
|
||||
"updateBadge": "Update available",
|
||||
"updateFailed": "Update installation failed, attempted to open download page.",
|
||||
"checkUpdateFailed": "Failed to check for updates, please try again later.",
|
||||
"openReleaseNotesFailed": "Failed to open release notes",
|
||||
@@ -348,6 +343,7 @@
|
||||
"anthropicModel": "Main Model",
|
||||
"anthropicSmallFastModel": "Fast Model",
|
||||
"anthropicReasoningModel": "Reasoning Model (Thinking)",
|
||||
"reasoningModelPlaceholder": "e.g. claude-sonnet-4-20250514",
|
||||
"openrouterCompatMode": "OpenRouter Compatibility Mode",
|
||||
"openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.",
|
||||
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
||||
@@ -398,11 +394,7 @@
|
||||
"editCommonConfig": "Edit Common Config",
|
||||
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
|
||||
"commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked",
|
||||
"apiUrlLabel": "API Request URL",
|
||||
"extractFromCurrent": "Extract from Editor",
|
||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
||||
"extractFailed": "Extract failed: {{error}}",
|
||||
"saveFailed": "Save failed: {{error}}"
|
||||
"apiUrlLabel": "API Request URL"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "Environment Variables (.env)",
|
||||
@@ -412,18 +404,7 @@
|
||||
"writeCommonConfig": "Write Common Config",
|
||||
"editCommonConfig": "Edit Common Config",
|
||||
"editCommonConfigTitle": "Edit Gemini Common Config Snippet",
|
||||
"commonConfigHint": "This snippet writes to Gemini .env (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)",
|
||||
"extractFromCurrent": "Extract from Editor",
|
||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
||||
"extractFailed": "Extract failed: {{error}}",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"extractedConfigInvalid": "Extracted config format is invalid",
|
||||
"invalidJsonFormat": "Common config snippet format error (must be valid JSON)",
|
||||
"commonConfigInvalidKeys": "Common config snippet must not include GOOGLE_GEMINI_BASE_URL or GEMINI_API_KEY (found: {{keys}})",
|
||||
"commonConfigInvalidValues": "Common config snippet values must be strings",
|
||||
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
|
||||
"configMergeFailed": "Config merge failed: {{error}}",
|
||||
"configReplaceFailed": "Config replace failed: {{error}}"
|
||||
"commonConfigHint": "Common config snippet will be merged into all Gemini providers with it enabled"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "Provider Preset",
|
||||
@@ -584,9 +565,6 @@
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Management",
|
||||
"import": "Import",
|
||||
"importExisting": "Import Existing",
|
||||
"addMcp": "Add MCP",
|
||||
"claudeTitle": "Claude Code MCP Management",
|
||||
"codexTitle": "Codex MCP Management",
|
||||
"geminiTitle": "Gemini MCP Management",
|
||||
@@ -598,8 +576,6 @@
|
||||
"deleteConfirm": "Are you sure you want to delete server \"{{id}}\"? This action cannot be undone.",
|
||||
"noServers": "No servers yet",
|
||||
"enabledApps": "Enabled Apps",
|
||||
"noImportFound": "No MCP servers to import found. All servers are already managed by CC Switch.",
|
||||
"importSuccess": "Successfully imported {{count}} MCP servers",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -817,8 +793,8 @@
|
||||
},
|
||||
"skills": {
|
||||
"manage": "Skills",
|
||||
"title": "Skills Management",
|
||||
"description": "Discover and install skills from popular repositories to extend Claude Code/Codex/Gemini capabilities",
|
||||
"title": "Claude Skills Management",
|
||||
"description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing...",
|
||||
"repoManager": "Repository Management",
|
||||
@@ -894,25 +870,7 @@
|
||||
"installed": "Installed",
|
||||
"uninstalled": "Not installed"
|
||||
},
|
||||
"noResults": "No matching skills found",
|
||||
"noInstalled": "No skills installed",
|
||||
"noInstalledDescription": "Discover and install skills from repositories, or import existing skills",
|
||||
"discover": "Discover Skills",
|
||||
"import": "Import Existing",
|
||||
"importDescription": "Select skills to import into CC Switch unified management",
|
||||
"importSuccess": "Successfully imported {{count}} skills",
|
||||
"importSelected": "Import Selected ({{count}})",
|
||||
"noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.",
|
||||
"foundIn": "Found in",
|
||||
"local": "Local",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps.",
|
||||
"uninstallInMainPanel": "Please uninstall skills from the main panel",
|
||||
"notFound": "Skill not found",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
}
|
||||
"noResults": "No matching skills found"
|
||||
},
|
||||
"deeplink": {
|
||||
"confirmImport": "Confirm Import Provider",
|
||||
@@ -1000,10 +958,7 @@
|
||||
"clickToSelect": "Click to select icon"
|
||||
},
|
||||
"migration": {
|
||||
"success": "Configuration migrated successfully",
|
||||
"skillsSuccess": "Automatically imported {{count}} skill(s) into unified management",
|
||||
"skillsFailed": "Failed to auto import skills",
|
||||
"skillsFailedDescription": "Open the Skills page and click \"Import Existing\" to import manually (or restart and try again)."
|
||||
"success": "Configuration migrated successfully"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents"
|
||||
|
||||
@@ -53,11 +53,7 @@
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "共通設定スニペットを編集",
|
||||
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
|
||||
"fullSettingsHint": "Claude Code の settings.json 全文",
|
||||
"extractFromCurrent": "編集内容から抽出",
|
||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
"fullSettingsHint": "Claude Code の settings.json 全文"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "GitHub で見る",
|
||||
@@ -251,7 +247,6 @@
|
||||
"aboutHint": "バージョン情報と更新状況を表示します。",
|
||||
"portableMode": "ポータブルモード: 更新は手動ダウンロードが必要です。",
|
||||
"updateAvailable": "新しいバージョンがあります: {{version}}",
|
||||
"updateBadge": "更新あり",
|
||||
"updateFailed": "更新のインストールに失敗しました。ダウンロードページを開こうとしました。",
|
||||
"checkUpdateFailed": "更新の確認に失敗しました。時間をおいて再試行してください。",
|
||||
"openReleaseNotesFailed": "リリースノートの表示に失敗しました",
|
||||
@@ -348,6 +343,7 @@
|
||||
"anthropicModel": "メインモデル",
|
||||
"anthropicSmallFastModel": "高速モデル",
|
||||
"anthropicReasoningModel": "推論モデル(Thinking)",
|
||||
"reasoningModelPlaceholder": "例: claude-sonnet-4-20250514",
|
||||
"openrouterCompatMode": "OpenRouter 互換モード",
|
||||
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
|
||||
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
||||
@@ -398,11 +394,7 @@
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
|
||||
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
|
||||
"apiUrlLabel": "API リクエスト URL",
|
||||
"extractFromCurrent": "編集内容から抽出",
|
||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
"apiUrlLabel": "API リクエスト URL"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境変数 (.env)",
|
||||
@@ -412,18 +404,7 @@
|
||||
"writeCommonConfig": "共通設定を書き込む",
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "Gemini 共通設定スニペットを編集",
|
||||
"commonConfigHint": "このスニペットは Gemini の .env に書き込みます(GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません)",
|
||||
"extractFromCurrent": "編集内容から抽出",
|
||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"extractedConfigInvalid": "抽出した設定のフォーマットが不正です",
|
||||
"invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)",
|
||||
"commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}})",
|
||||
"commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります",
|
||||
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
||||
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
|
||||
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
|
||||
"commonConfigHint": "共通設定スニペットは、この機能をオンにしたすべての Gemini プロバイダーへマージされます"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "プロバイダータイプ",
|
||||
@@ -584,9 +565,6 @@
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP 管理",
|
||||
"import": "インポート",
|
||||
"importExisting": "既存をインポート",
|
||||
"addMcp": "MCPを追加",
|
||||
"claudeTitle": "Claude Code MCP 管理",
|
||||
"codexTitle": "Codex MCP 管理",
|
||||
"geminiTitle": "Gemini MCP 管理",
|
||||
@@ -598,8 +576,6 @@
|
||||
"deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。",
|
||||
"noServers": "まだサーバーがありません",
|
||||
"enabledApps": "有効なアプリ",
|
||||
"noImportFound": "インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。",
|
||||
"importSuccess": "{{count}} 個の MCP サーバーをインポートしました",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -817,8 +793,8 @@
|
||||
},
|
||||
"skills": {
|
||||
"manage": "Skills",
|
||||
"title": "Skills 管理",
|
||||
"description": "人気リポジトリからスキルを探してインストールし、Claude Code/Codex/Gemini を拡張",
|
||||
"title": "Claude スキル管理",
|
||||
"description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張",
|
||||
"refresh": "更新",
|
||||
"refreshing": "更新中...",
|
||||
"repoManager": "リポジトリ管理",
|
||||
@@ -894,25 +870,7 @@
|
||||
"installed": "インストール済み",
|
||||
"uninstalled": "未インストール"
|
||||
},
|
||||
"noResults": "一致するスキルが見つかりませんでした",
|
||||
"noInstalled": "インストールされたスキルがありません",
|
||||
"noInstalledDescription": "リポジトリからスキルを発見してインストールするか、既存のスキルをインポートしてください",
|
||||
"discover": "スキルを発見",
|
||||
"import": "既存をインポート",
|
||||
"importDescription": "CC Switch 統合管理にインポートするスキルを選択してください",
|
||||
"importSuccess": "{{count}} 件のスキルをインポートしました",
|
||||
"importSelected": "選択をインポート ({{count}})",
|
||||
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
|
||||
"foundIn": "発見場所",
|
||||
"local": "ローカル",
|
||||
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除されます。",
|
||||
"uninstallInMainPanel": "メインパネルからスキルをアンインストールしてください",
|
||||
"notFound": "スキルが見つかりません",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
}
|
||||
"noResults": "一致するスキルが見つかりませんでした"
|
||||
},
|
||||
"deeplink": {
|
||||
"confirmImport": "プロバイダーのインポートを確認",
|
||||
@@ -1000,10 +958,7 @@
|
||||
"clickToSelect": "クリックでアイコンを選択"
|
||||
},
|
||||
"migration": {
|
||||
"success": "設定の移行が完了しました",
|
||||
"skillsSuccess": "スキルを {{count}} 件、自動的に統合管理へインポートしました",
|
||||
"skillsFailed": "スキルの自動インポートに失敗しました",
|
||||
"skillsFailedDescription": "Skills 画面で「既存をインポート」をクリックして手動でインポートしてください(または再起動して再試行)。"
|
||||
"success": "設定の移行が完了しました"
|
||||
},
|
||||
"agents": {
|
||||
"title": "エージェント"
|
||||
|
||||
@@ -53,11 +53,7 @@
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "编辑通用配置片段",
|
||||
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
|
||||
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
|
||||
"extractFromCurrent": "从编辑内容提取",
|
||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||
"extractFailed": "提取失败: {{error}}",
|
||||
"saveFailed": "保存失败: {{error}}"
|
||||
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "在 GitHub 上查看",
|
||||
@@ -251,7 +247,6 @@
|
||||
"aboutHint": "查看版本信息与更新状态。",
|
||||
"portableMode": "当前为便携版,更新需手动下载。",
|
||||
"updateAvailable": "检测到新版本:{{version}}",
|
||||
"updateBadge": "有更新可用",
|
||||
"updateFailed": "更新安装失败,已尝试打开下载页面。",
|
||||
"checkUpdateFailed": "检查更新失败,请稍后重试。",
|
||||
"openReleaseNotesFailed": "打开更新日志失败",
|
||||
@@ -348,6 +343,7 @@
|
||||
"anthropicModel": "主模型",
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"anthropicReasoningModel": "推理模型 (Thinking)",
|
||||
"reasoningModelPlaceholder": "如 claude-sonnet-4-20250514",
|
||||
"openrouterCompatMode": "OpenRouter 兼容模式",
|
||||
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
||||
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
||||
@@ -398,11 +394,7 @@
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
|
||||
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
|
||||
"apiUrlLabel": "API 请求地址",
|
||||
"extractFromCurrent": "从编辑内容提取",
|
||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||
"extractFailed": "提取失败: {{error}}",
|
||||
"saveFailed": "保存失败: {{error}}"
|
||||
"apiUrlLabel": "API 请求地址"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "环境变量 (.env)",
|
||||
@@ -412,18 +404,7 @@
|
||||
"writeCommonConfig": "写入通用配置",
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "编辑 Gemini 通用配置片段",
|
||||
"commonConfigHint": "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
||||
"extractFromCurrent": "从编辑内容提取",
|
||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||
"extractFailed": "提取失败: {{error}}",
|
||||
"saveFailed": "保存失败: {{error}}",
|
||||
"extractedConfigInvalid": "提取的配置格式错误",
|
||||
"invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)",
|
||||
"commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}})",
|
||||
"commonConfigInvalidValues": "通用配置片段的值必须是字符串",
|
||||
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
||||
"configMergeFailed": "配置合并失败: {{error}}",
|
||||
"configReplaceFailed": "配置替换失败: {{error}}"
|
||||
"commonConfigHint": "通用配置片段将合并到所有启用它的 Gemini 供应商配置中"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "预设供应商",
|
||||
@@ -584,9 +565,6 @@
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP 管理",
|
||||
"import": "导入",
|
||||
"importExisting": "导入已有",
|
||||
"addMcp": "添加MCP",
|
||||
"claudeTitle": "Claude Code MCP 管理",
|
||||
"codexTitle": "Codex MCP 管理",
|
||||
"geminiTitle": "Gemini MCP 管理",
|
||||
@@ -598,8 +576,6 @@
|
||||
"deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。",
|
||||
"noServers": "暂无服务器",
|
||||
"enabledApps": "启用的应用",
|
||||
"noImportFound": "未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。",
|
||||
"importSuccess": "成功导入 {{count}} 个 MCP 服务器",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -817,8 +793,8 @@
|
||||
},
|
||||
"skills": {
|
||||
"manage": "Skills",
|
||||
"title": "Skills 管理",
|
||||
"description": "从流行的仓库发现并安装技能,扩展 Claude Code/Codex/Gemini 的能力",
|
||||
"title": "Claude Skills 管理",
|
||||
"description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力",
|
||||
"refresh": "刷新",
|
||||
"refreshing": "刷新中...",
|
||||
"repoManager": "仓库管理",
|
||||
@@ -894,25 +870,7 @@
|
||||
"installed": "已安装",
|
||||
"uninstalled": "未安装"
|
||||
},
|
||||
"noResults": "未找到匹配的技能",
|
||||
"noInstalled": "暂无已安装的技能",
|
||||
"noInstalledDescription": "从仓库发现并安装技能,或导入已有的技能",
|
||||
"discover": "发现技能",
|
||||
"import": "导入已有",
|
||||
"importDescription": "选择要导入到 CC Switch 统一管理的技能",
|
||||
"importSuccess": "成功导入 {{count}} 个技能",
|
||||
"importSelected": "导入已选 ({{count}})",
|
||||
"noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。",
|
||||
"foundIn": "发现于",
|
||||
"local": "本地",
|
||||
"uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能。",
|
||||
"uninstallInMainPanel": "请在主面板中卸载技能",
|
||||
"notFound": "未找到技能",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
}
|
||||
"noResults": "未找到匹配的技能"
|
||||
},
|
||||
"deeplink": {
|
||||
"confirmImport": "确认导入供应商配置",
|
||||
@@ -1000,10 +958,7 @@
|
||||
"clickToSelect": "点击选择图标"
|
||||
},
|
||||
"migration": {
|
||||
"success": "配置迁移成功",
|
||||
"skillsSuccess": "已自动导入 {{count}} 个技能到统一管理",
|
||||
"skillsFailed": "自动导入技能失败",
|
||||
"skillsFailedDescription": "请打开 Skills 页面点击“导入已有”手动导入(或重启后再试)。"
|
||||
"success": "配置迁移成功"
|
||||
},
|
||||
"agents": {
|
||||
"title": "智能体"
|
||||
|
||||
@@ -47,31 +47,3 @@ export async function setCommonConfigSnippet(
|
||||
): Promise<void> {
|
||||
return invoke("set_common_config_snippet", { appType, snippet });
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取通用配置片段
|
||||
*
|
||||
* 默认读取当前激活供应商的配置;若传入 `options.settingsConfig`,则从编辑器当前内容提取。
|
||||
* 会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
|
||||
*
|
||||
* @param appType - 应用类型(claude/codex/gemini)
|
||||
* @param options - 可选:提取来源
|
||||
* @returns 提取的通用配置片段(JSON/TOML 字符串)
|
||||
*/
|
||||
export type ExtractCommonConfigSnippetOptions = {
|
||||
settingsConfig?: string;
|
||||
};
|
||||
|
||||
export async function extractCommonConfigSnippet(
|
||||
appType: AppType,
|
||||
options?: ExtractCommonConfigSnippetOptions,
|
||||
): Promise<string> {
|
||||
const args: Record<string, unknown> = { appType };
|
||||
const settingsConfig = options?.settingsConfig;
|
||||
|
||||
if (typeof settingsConfig === "string" && settingsConfig.trim()) {
|
||||
args.settingsConfig = settingsConfig;
|
||||
}
|
||||
|
||||
return invoke<string>("extract_common_config_snippet", args);
|
||||
}
|
||||
|
||||
@@ -119,11 +119,4 @@ export const mcpApi = {
|
||||
): Promise<void> {
|
||||
return await invoke("toggle_mcp_app", { serverId, app, enabled });
|
||||
},
|
||||
|
||||
/**
|
||||
* 从所有应用导入 MCP 服务器
|
||||
*/
|
||||
async importFromApps(): Promise<number> {
|
||||
return await invoke("import_mcp_from_apps");
|
||||
},
|
||||
};
|
||||
|
||||
+1
-102
@@ -1,51 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
export type AppType = "claude" | "codex" | "gemini";
|
||||
|
||||
/** Skill 应用启用状态 */
|
||||
export interface SkillApps {
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
}
|
||||
|
||||
/** 已安装的 Skill(v3.10.0+ 统一结构) */
|
||||
export interface InstalledSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
directory: string;
|
||||
repoOwner?: string;
|
||||
repoName?: string;
|
||||
repoBranch?: string;
|
||||
readmeUrl?: string;
|
||||
apps: SkillApps;
|
||||
installedAt: number;
|
||||
}
|
||||
|
||||
/** 可发现的 Skill(来自仓库) */
|
||||
export interface DiscoverableSkill {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
directory: string;
|
||||
readmeUrl?: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
repoBranch: string;
|
||||
}
|
||||
|
||||
/** 未管理的 Skill(用于导入) */
|
||||
export interface UnmanagedSkill {
|
||||
directory: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
foundIn: string[];
|
||||
}
|
||||
|
||||
/** 技能对象(兼容旧 API) */
|
||||
export interface Skill {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -58,7 +12,6 @@ export interface Skill {
|
||||
repoBranch?: string;
|
||||
}
|
||||
|
||||
/** 仓库配置 */
|
||||
export interface SkillRepo {
|
||||
owner: string;
|
||||
name: string;
|
||||
@@ -66,56 +19,9 @@ export interface SkillRepo {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ========== API ==========
|
||||
export type AppType = "claude" | "codex" | "gemini";
|
||||
|
||||
export const skillsApi = {
|
||||
// ========== 统一管理 API (v3.10.0+) ==========
|
||||
|
||||
/** 获取所有已安装的 Skills */
|
||||
async getInstalled(): Promise<InstalledSkill[]> {
|
||||
return await invoke("get_installed_skills");
|
||||
},
|
||||
|
||||
/** 安装 Skill(统一安装) */
|
||||
async installUnified(
|
||||
skill: DiscoverableSkill,
|
||||
currentApp: AppType,
|
||||
): Promise<InstalledSkill> {
|
||||
return await invoke("install_skill_unified", { skill, currentApp });
|
||||
},
|
||||
|
||||
/** 卸载 Skill(统一卸载) */
|
||||
async uninstallUnified(id: string): Promise<boolean> {
|
||||
return await invoke("uninstall_skill_unified", { id });
|
||||
},
|
||||
|
||||
/** 切换 Skill 的应用启用状态 */
|
||||
async toggleApp(
|
||||
id: string,
|
||||
app: AppType,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
return await invoke("toggle_skill_app", { id, app, enabled });
|
||||
},
|
||||
|
||||
/** 扫描未管理的 Skills */
|
||||
async scanUnmanaged(): Promise<UnmanagedSkill[]> {
|
||||
return await invoke("scan_unmanaged_skills");
|
||||
},
|
||||
|
||||
/** 从应用目录导入 Skills */
|
||||
async importFromApps(directories: string[]): Promise<InstalledSkill[]> {
|
||||
return await invoke("import_skills_from_apps", { directories });
|
||||
},
|
||||
|
||||
/** 发现可安装的 Skills(从仓库获取) */
|
||||
async discoverAvailable(): Promise<DiscoverableSkill[]> {
|
||||
return await invoke("discover_available_skills");
|
||||
},
|
||||
|
||||
// ========== 兼容旧 API ==========
|
||||
|
||||
/** 获取技能列表(兼容旧 API) */
|
||||
async getAll(app: AppType = "claude"): Promise<Skill[]> {
|
||||
if (app === "claude") {
|
||||
return await invoke("get_skills");
|
||||
@@ -123,7 +29,6 @@ export const skillsApi = {
|
||||
return await invoke("get_skills_for_app", { app });
|
||||
},
|
||||
|
||||
/** 安装技能(兼容旧 API) */
|
||||
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
|
||||
if (app === "claude") {
|
||||
return await invoke("install_skill", { directory });
|
||||
@@ -131,7 +36,6 @@ export const skillsApi = {
|
||||
return await invoke("install_skill_for_app", { app, directory });
|
||||
},
|
||||
|
||||
/** 卸载技能(兼容旧 API) */
|
||||
async uninstall(
|
||||
directory: string,
|
||||
app: AppType = "claude",
|
||||
@@ -142,19 +46,14 @@ export const skillsApi = {
|
||||
return await invoke("uninstall_skill_for_app", { app, directory });
|
||||
},
|
||||
|
||||
// ========== 仓库管理 ==========
|
||||
|
||||
/** 获取仓库列表 */
|
||||
async getRepos(): Promise<SkillRepo[]> {
|
||||
return await invoke("get_skill_repos");
|
||||
},
|
||||
|
||||
/** 添加仓库 */
|
||||
async addRepo(repo: SkillRepo): Promise<boolean> {
|
||||
return await invoke("add_skill_repo", { repo });
|
||||
},
|
||||
|
||||
/** 删除仓库 */
|
||||
async removeRepo(owner: string, name: string): Promise<boolean> {
|
||||
return await invoke("remove_skill_repo", { owner, name });
|
||||
},
|
||||
|
||||
@@ -36,8 +36,6 @@ const withJson = async <T>(request: Request): Promise<T> => {
|
||||
const success = <T>(payload: T) => HttpResponse.json(payload as any);
|
||||
|
||||
export const handlers = [
|
||||
http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)),
|
||||
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null)),
|
||||
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
return success(getProviders(app));
|
||||
|
||||
Reference in New Issue
Block a user