mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce9e5e25d3 | |||
| 01eee0ae22 | |||
| 37239f292c | |||
| d954df0c76 |
@@ -56,8 +56,7 @@ jobs:
|
||||
libssl-dev \
|
||||
rpm \
|
||||
flatpak \
|
||||
flatpak-builder \
|
||||
elfutils
|
||||
flatpak-builder
|
||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev \
|
||||
@@ -284,6 +283,19 @@ jobs:
|
||||
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 --user --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
|
||||
@@ -312,7 +324,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)
|
||||
- **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)
|
||||
|
||||
---
|
||||
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
|
||||
@@ -9,30 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [3.9.1] - 2026-01-09
|
||||
|
||||
### Bug Fix Release
|
||||
|
||||
This release focuses on stability improvements and crash prevention.
|
||||
|
||||
### Added
|
||||
|
||||
- **Crash Logging** - Panic hook captures crash info to `~/.cc-switch/crash.log` with full stack traces (#562)
|
||||
- **Release Logging** - Enable logging for release builds with automatic rotation (keeps 2 most recent files)
|
||||
- **AIGoCode Icon** - Added colored icon for AIGoCode provider preset
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Proxy Panic Prevention** - Graceful degradation when HTTP client initialization fails due to invalid proxy settings; falls back to no_proxy mode (#560)
|
||||
- **UTF-8 Safety** - Fix potential panic when masking API keys or truncating logs containing multi-byte characters (Chinese, emoji, etc.) (#560)
|
||||
- **Default Proxy Port** - Change default port from 5000 to 15721 to avoid conflict with macOS AirPlay Receiver (#560)
|
||||
- **Windows Title** - Display "CC Switch" instead of default "Tauri app" in window title
|
||||
- **Windows/Linux Spacing** - Remove extra 28px blank space below native titlebar introduced in v3.9.0
|
||||
- **Flatpak Tray Icon** - Bundle libayatana-appindicator for tray icon support on Flatpak (#556)
|
||||
- **Provider Preset** - Correct casing from "AiGoCode" to "AIGoCode" to match official branding
|
||||
|
||||
---
|
||||
|
||||
## [3.9.0] - 2026-01-07
|
||||
|
||||
### Stable Release
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -52,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
||||
|
||||
## Features
|
||||
|
||||
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||
### Current Version: v3.9.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||
|
||||
**v3.8.0 Major Update (2025-11-28)**
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## 特長
|
||||
|
||||
### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||
### 現在のバージョン:v3.9.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||
|
||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
||||
### 当前版本:v3.9.0 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
||||
|
||||
**v3.8.0 重大更新(2025-11-28)**
|
||||
|
||||
|
||||
+776
-239
File diff suppressed because it is too large
Load Diff
@@ -22,15 +22,6 @@ finish-args:
|
||||
- --filesystem=home
|
||||
|
||||
modules:
|
||||
# Required for libdbusmenu build (intltool was removed from GNOME SDK since 2019)
|
||||
- name: intltool
|
||||
cleanup:
|
||||
- "*"
|
||||
sources:
|
||||
- type: archive
|
||||
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
|
||||
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
|
||||
|
||||
# Required for tray icon support
|
||||
- name: libayatana-ido
|
||||
buildsystem: cmake-ninja
|
||||
@@ -49,7 +40,7 @@ modules:
|
||||
- --with-gtk=3
|
||||
- --disable-dumper
|
||||
- --disable-static
|
||||
- --disable-nls
|
||||
- --enable-tests=no
|
||||
sources:
|
||||
- type: archive
|
||||
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.9.1",
|
||||
"version": "3.9.0",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-2
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.9.1"
|
||||
version = "3.9.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -727,7 +727,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.9.1"
|
||||
version = "3.9.0"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -61,7 +61,6 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
@@ -78,8 +77,7 @@ objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
opt-level = "s"
|
||||
# 使用 unwind 以便 panic hook 能捕获 backtrace(abort 会直接终止无法捕获)
|
||||
panic = "unwind"
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -65,30 +65,6 @@ fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
|
||||
// 非 Windows 平台不做任何处理
|
||||
}
|
||||
|
||||
/// 检测路径是否为 WSL 网络路径(如 \\wsl$\Ubuntu\... 或 \\wsl.localhost\Ubuntu\...)
|
||||
/// WSL 环境运行的是 Linux,不需要 cmd /c 包装
|
||||
/// 注意:仅检测直接 UNC 路径,映射磁盘符(如 Z: -> \\wsl$\...)无法检测
|
||||
#[cfg(windows)]
|
||||
fn is_wsl_path(path: &Path) -> bool {
|
||||
use std::path::{Component, Prefix};
|
||||
if let Some(Component::Prefix(prefix)) = path.components().next() {
|
||||
match prefix.kind() {
|
||||
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {
|
||||
let s = server.to_string_lossy();
|
||||
s.eq_ignore_ascii_case("wsl$") || s.eq_ignore_ascii_case("wsl.localhost")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_wsl_path(_path: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpStatus {
|
||||
@@ -395,11 +371,6 @@ pub fn set_mcp_servers_map(
|
||||
};
|
||||
|
||||
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||
// 检测目标路径是否为 WSL,若是则跳过 cmd /c 包装
|
||||
let is_wsl_target = is_wsl_path(&path);
|
||||
if is_wsl_target {
|
||||
log::info!("检测到 WSL 路径,跳过 cmd /c 包装: {}", path.display());
|
||||
}
|
||||
let mut out: Map<String, Value> = Map::new();
|
||||
for (id, spec) in servers.iter() {
|
||||
let mut obj = if let Some(map) = spec.as_object() {
|
||||
@@ -426,10 +397,8 @@ pub fn set_mcp_servers_map(
|
||||
obj.remove("homepage");
|
||||
obj.remove("docs");
|
||||
|
||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式(WSL 路径除外)
|
||||
if !is_wsl_target {
|
||||
wrap_command_for_windows(&mut obj);
|
||||
}
|
||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
out.insert(id.clone(), Value::Object(obj));
|
||||
}
|
||||
@@ -576,68 +545,4 @@ mod tests {
|
||||
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 WSL 路径检测功能
|
||||
#[test]
|
||||
fn test_is_wsl_path_wsl_dollar() {
|
||||
// wsl$ 格式 - 各种发行版
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Debian\home\user\.claude")));
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl$\openSUSE-Leap-15.2\home\user"
|
||||
)));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\kali-linux\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Arch\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Alpine\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Fedora\home\user")));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// 非 Windows 平台始终返回 false
|
||||
assert!(!is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_wsl_localhost() {
|
||||
// wsl.localhost 格式
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl.localhost\Ubuntu\home\user\.claude"
|
||||
)));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl.localhost\Debian\home\user")));
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl.localhost\openSUSE-Leap-15.2\home\user"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_case_insensitive() {
|
||||
// 大小写不敏感
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(r"\\WSL$\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\Wsl$\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\WSL.LOCALHOST\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\Wsl.Localhost\Ubuntu\home\user")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_non_wsl() {
|
||||
// 非 WSL 路径
|
||||
assert!(!is_wsl_path(Path::new(r"C:\Users\user\.claude")));
|
||||
assert!(!is_wsl_path(Path::new(r"D:\Workspace\project")));
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(!is_wsl_path(Path::new(r"\\server\share\path")));
|
||||
assert!(!is_wsl_path(Path::new(r"\\localhost\c$\Users")));
|
||||
assert!(!is_wsl_path(Path::new(r"\\192.168.1.1\share")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,21 +9,13 @@ use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
get_home_dir().join(".codex")
|
||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
||||
}
|
||||
|
||||
/// 获取 Codex auth.json 路径
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
//! 全局出站代理相关命令
|
||||
//!
|
||||
//! 提供获取、设置和测试全局代理的 Tauri 命令。
|
||||
|
||||
use crate::proxy::http_client;
|
||||
use crate::store::AppState;
|
||||
use serde::Serialize;
|
||||
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 获取全局代理 URL
|
||||
///
|
||||
/// 返回当前配置的代理 URL,null 表示直连。
|
||||
#[tauri::command]
|
||||
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
|
||||
let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;
|
||||
log::debug!(
|
||||
"[GlobalProxy] [GP-010] Read from database: {}",
|
||||
result
|
||||
.as_ref()
|
||||
.map(|u| http_client::mask_url(u))
|
||||
.unwrap_or_else(|| "None".to_string())
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 设置全局代理 URL
|
||||
///
|
||||
/// - 传入非空字符串:启用代理
|
||||
/// - 传入空字符串:清除代理(直连)
|
||||
///
|
||||
/// 执行顺序:先验证 → 写 DB → 再应用
|
||||
/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题
|
||||
#[tauri::command]
|
||||
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
|
||||
// 调试:显示接收到的 URL 信息(不包含敏感内容)
|
||||
let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks"));
|
||||
log::debug!(
|
||||
"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}",
|
||||
url.len(),
|
||||
has_auth
|
||||
);
|
||||
|
||||
let url_opt = if url.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(url.as_str())
|
||||
};
|
||||
|
||||
// 1. 先验证代理配置是否有效(不应用)
|
||||
http_client::validate_proxy(url_opt)?;
|
||||
|
||||
// 2. 验证成功后保存到数据库
|
||||
state
|
||||
.db
|
||||
.set_global_proxy_url(url_opt)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 3. DB 写入成功后再应用到运行态
|
||||
http_client::apply_proxy(url_opt)?;
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] [GP-009] Configuration updated: {}",
|
||||
url_opt
|
||||
.map(http_client::mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 代理测试结果
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyTestResult {
|
||||
/// 是否连接成功
|
||||
pub success: bool,
|
||||
/// 延迟(毫秒)
|
||||
pub latency_ms: u64,
|
||||
/// 错误信息
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 测试代理连接
|
||||
///
|
||||
/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。
|
||||
/// 使用多个测试目标,任一成功即认为代理可用。
|
||||
#[tauri::command]
|
||||
pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
|
||||
if url.trim().is_empty() {
|
||||
return Err("Proxy URL is empty".to_string());
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// 构建带代理的临时客户端
|
||||
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build client: {e}"))?;
|
||||
|
||||
// 使用多个测试目标,提高兼容性
|
||||
// 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点
|
||||
let test_urls = [
|
||||
"https://httpbin.org/get",
|
||||
"https://www.google.com",
|
||||
"https://api.anthropic.com",
|
||||
];
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for test_url in test_urls {
|
||||
match client.head(test_url).send().await {
|
||||
Ok(resp) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)",
|
||||
http_client::mask_url(&url),
|
||||
test_url,
|
||||
resp.status(),
|
||||
latency
|
||||
);
|
||||
return Ok(ProxyTestResult {
|
||||
success: true,
|
||||
latency_ms: latency,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("[GlobalProxy] Test to {test_url} failed: {e}");
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有测试目标都失败
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
let error_msg = last_error
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "All test targets failed".to_string());
|
||||
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
|
||||
http_client::mask_url(&url),
|
||||
error_msg,
|
||||
latency
|
||||
);
|
||||
|
||||
Ok(ProxyTestResult {
|
||||
success: false,
|
||||
latency_ms: latency,
|
||||
error: Some(error_msg),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取当前出站代理状态
|
||||
///
|
||||
/// 返回当前是否启用了出站代理以及代理 URL。
|
||||
#[tauri::command]
|
||||
pub fn get_upstream_proxy_status() -> UpstreamProxyStatus {
|
||||
let url = http_client::get_current_proxy_url();
|
||||
UpstreamProxyStatus {
|
||||
enabled: url.is_some(),
|
||||
proxy_url: url,
|
||||
}
|
||||
}
|
||||
|
||||
/// 出站代理状态信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpstreamProxyStatus {
|
||||
/// 是否启用代理
|
||||
pub enabled: bool,
|
||||
/// 代理 URL
|
||||
pub proxy_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 检测到的代理信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DetectedProxy {
|
||||
/// 代理 URL
|
||||
pub url: String,
|
||||
/// 代理类型 (http/socks5)
|
||||
pub proxy_type: String,
|
||||
/// 端口
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// 常见代理端口配置
|
||||
/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5)
|
||||
/// 对于 mixed 端口,会同时返回两种协议供用户选择
|
||||
const PROXY_PORTS: &[(u16, &str, bool)] = &[
|
||||
(7890, "http", true), // Clash (mixed mode)
|
||||
(7891, "socks5", false), // Clash SOCKS only
|
||||
(1080, "socks5", false), // 通用 SOCKS5
|
||||
(8080, "http", false), // 通用 HTTP
|
||||
(8888, "http", false), // Charles/Fiddler
|
||||
(3128, "http", false), // Squid
|
||||
(10808, "socks5", false), // V2Ray SOCKS
|
||||
(10809, "http", false), // V2Ray HTTP
|
||||
];
|
||||
|
||||
/// 扫描本地代理
|
||||
///
|
||||
/// 检测常见端口是否有代理服务在运行。
|
||||
/// 使用异步任务避免阻塞 UI 线程。
|
||||
#[tauri::command]
|
||||
pub async fn scan_local_proxies() -> Vec<DetectedProxy> {
|
||||
// 使用 spawn_blocking 避免阻塞主线程
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let mut found = Vec::new();
|
||||
|
||||
for &(port, primary_type, is_mixed) in PROXY_PORTS {
|
||||
let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
|
||||
if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {
|
||||
// 添加主要类型
|
||||
found.push(DetectedProxy {
|
||||
url: format!("{primary_type}://127.0.0.1:{port}"),
|
||||
proxy_type: primary_type.to_string(),
|
||||
port,
|
||||
});
|
||||
// 对于 mixed 端口,同时添加另一种协议
|
||||
if is_mixed {
|
||||
let alt_type = if primary_type == "http" {
|
||||
"socks5"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
found.push(DetectedProxy {
|
||||
url: format!("{alt_type}://127.0.0.1:{port}"),
|
||||
proxy_type: alt_type.to_string(),
|
||||
port,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
+12
-426
@@ -1,14 +1,7 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||
use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -92,14 +85,15 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||
let tools = vec!["claude", "codex", "gemini"];
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
// 用于获取远程版本的 client
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("cc-switch/1.0")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for tool in tools {
|
||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
try_get_version_wsl(tool, &distro)
|
||||
} else {
|
||||
let (local_version, local_error) = {
|
||||
// 先尝试直接执行
|
||||
let direct_result = try_get_version(tool);
|
||||
|
||||
@@ -148,14 +142,11 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
|
||||
}
|
||||
}
|
||||
|
||||
/// 预编译的版本号正则表达式
|
||||
static VERSION_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
|
||||
|
||||
/// 从版本输出中提取纯版本号
|
||||
fn extract_version(raw: &str) -> String {
|
||||
VERSION_RE
|
||||
.find(raw)
|
||||
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
|
||||
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
|
||||
re.find(raw)
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_else(|| raw.to_string())
|
||||
}
|
||||
@@ -187,7 +178,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if raw.is_empty() {
|
||||
(None, Some("not installed or not executable".to_string()))
|
||||
(None, Some("未安装或无法执行".to_string()))
|
||||
} else {
|
||||
(Some(extract_version(raw)), None)
|
||||
}
|
||||
@@ -196,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some(if err.is_empty() {
|
||||
"not installed or not executable".to_string()
|
||||
"未安装或无法执行".to_string()
|
||||
} else {
|
||||
err
|
||||
}),
|
||||
@@ -207,88 +198,6 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 WSL 发行版名称是否合法
|
||||
/// WSL 发行版名称只允许字母、数字、连字符和下划线
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
|
||||
// 防御性断言:tool 只能是预定义的值
|
||||
debug_assert!(
|
||||
["claude", "codex", "gemini"].contains(&tool),
|
||||
"unexpected tool name: {tool}"
|
||||
);
|
||||
|
||||
// 校验 distro 名称,防止命令注入
|
||||
if !is_valid_wsl_distro_name(distro) {
|
||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||||
}
|
||||
|
||||
let output = Command::new("wsl.exe")
|
||||
.args([
|
||||
"-d",
|
||||
distro,
|
||||
"--",
|
||||
"sh",
|
||||
"-lc",
|
||||
&format!("{tool} --version"),
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if raw.is_empty() {
|
||||
(
|
||||
None,
|
||||
Some(format!("[WSL:{distro}] not installed or not executable")),
|
||||
)
|
||||
} else {
|
||||
(Some(extract_version(raw)), None)
|
||||
}
|
||||
} else {
|
||||
let err = if stderr.is_empty() { stdout } else { stderr };
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"[WSL:{distro}] {}",
|
||||
if err.is_empty() {
|
||||
"not installed or not executable".to_string()
|
||||
} else {
|
||||
err
|
||||
}
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows 平台的 WSL 版本检测存根
|
||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some("WSL check not supported on this platform".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// 扫描常见路径查找 CLI
|
||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
@@ -384,328 +293,5 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
(None, Some("not installed or not executable".to_string()))
|
||||
}
|
||||
|
||||
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
let override_dir = match tool {
|
||||
"claude" => crate::settings::get_claude_override_dir(),
|
||||
"codex" => crate::settings::get_codex_override_dir(),
|
||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
wsl_distro_from_path(&override_dir)
|
||||
}
|
||||
|
||||
/// 从 UNC 路径中提取 WSL 发行版名称
|
||||
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_distro_from_path(path: &Path) -> Option<String> {
|
||||
use std::path::{Component, Prefix};
|
||||
let Some(Component::Prefix(prefix)) = path.components().next() else {
|
||||
return None;
|
||||
};
|
||||
match prefix.kind() {
|
||||
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
|
||||
let server_name = server.to_string_lossy();
|
||||
if server_name.eq_ignore_ascii_case("wsl$")
|
||||
|| server_name.eq_ignore_ascii_case("wsl.localhost")
|
||||
{
|
||||
let distro = share.to_string_lossy().to_string();
|
||||
if !distro.is_empty() {
|
||||
return Some(distro);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows 平台不支持 WSL 路径解析
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 打开指定提供商的终端
|
||||
///
|
||||
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||||
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn open_provider_terminal(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app: String,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
|
||||
// 获取提供商配置
|
||||
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||||
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
|
||||
|
||||
let provider = providers
|
||||
.get(&providerId)
|
||||
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
|
||||
|
||||
// 从提供商配置中提取环境变量
|
||||
let config = &provider.settings_config;
|
||||
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||||
|
||||
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||||
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 从提供商配置中提取环境变量
|
||||
fn extract_env_vars_from_config(
|
||||
config: &serde_json::Value,
|
||||
app_type: &AppType,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut env_vars = Vec::new();
|
||||
|
||||
let Some(obj) = config.as_object() else {
|
||||
return env_vars;
|
||||
};
|
||||
|
||||
// 处理 env 字段(Claude/Gemini 通用)
|
||||
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
|
||||
for (key, value) in env {
|
||||
if let Some(str_val) = value.as_str() {
|
||||
env_vars.push((key.clone(), str_val.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 base_url: 根据应用类型添加对应的环境变量
|
||||
let base_url_key = match app_type {
|
||||
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
|
||||
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(key) = base_url_key {
|
||||
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
|
||||
env_vars.push((key.to_string(), url_str.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
|
||||
if *app_type == AppType::Codex {
|
||||
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
|
||||
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
|
||||
if *app_type == AppType::Gemini {
|
||||
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
|
||||
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
env_vars
|
||||
}
|
||||
|
||||
/// 创建临时配置文件并启动 claude 终端
|
||||
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||||
fn launch_terminal_with_env(
|
||||
env_vars: Vec<(String, String)>,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let config_file = temp_dir.join(format!(
|
||||
"claude_{}_{}.json",
|
||||
provider_id,
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
// 创建并写入配置文件
|
||||
write_claude_config(&config_file, &env_vars)?;
|
||||
|
||||
// 转义配置文件路径用于 shell
|
||||
let config_path_escaped = escape_shell_path(&config_file);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
launch_linux_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
launch_windows_terminal(&temp_dir, &config_file)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
Err("不支持的操作系统".to_string())
|
||||
}
|
||||
|
||||
/// 写入 claude 配置文件
|
||||
fn write_claude_config(
|
||||
config_file: &std::path::Path,
|
||||
env_vars: &[(String, String)],
|
||||
) -> Result<(), String> {
|
||||
let mut config_obj = serde_json::Map::new();
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
|
||||
for (key, value) in env_vars {
|
||||
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
|
||||
}
|
||||
|
||||
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
|
||||
|
||||
let config_json =
|
||||
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
|
||||
|
||||
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||
}
|
||||
|
||||
/// 转义 shell 路径
|
||||
fn escape_shell_path(path: &std::path::Path) -> String {
|
||||
path.to_string_lossy()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('$', "\\$")
|
||||
.replace(' ', "\\ ")
|
||||
}
|
||||
|
||||
/// 生成 bash 包装脚本,用于清理临时文件
|
||||
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
||||
format!(
|
||||
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: 使用 Terminal.app 启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
|
||||
|
||||
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
|
||||
|
||||
let script = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "{}"
|
||||
end tell"#,
|
||||
shell_script.replace('\"', "\\\"")
|
||||
);
|
||||
|
||||
Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&script)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Linux: 尝试使用常见终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let terminals = [
|
||||
"gnome-terminal",
|
||||
"konsole",
|
||||
"xfce4-terminal",
|
||||
"mate-terminal",
|
||||
"lxterminal",
|
||||
"alacritty",
|
||||
"kitty",
|
||||
];
|
||||
|
||||
let config_path_for_bash = config_file.to_string_lossy();
|
||||
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
|
||||
|
||||
let mut last_error = String::from("未找到可用的终端");
|
||||
|
||||
for terminal in terminals {
|
||||
// 检查终端是否存在
|
||||
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||||
{
|
||||
let result = match terminal {
|
||||
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
|
||||
.arg("--")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
_ => Command::new(terminal)
|
||||
.arg("-e")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
last_error = format!("启动 {} 失败: {}", terminal, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理配置文件
|
||||
let _ = std::fs::remove_file(config_file);
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
/// Windows: 创建临时批处理文件启动
|
||||
#[cfg(target_os = "windows")]
|
||||
fn launch_windows_terminal(
|
||||
temp_dir: &std::path::Path,
|
||||
config_file: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||
|
||||
let content = format!(
|
||||
"@echo off
|
||||
echo Using provider-specific claude config:
|
||||
echo {}
|
||||
claude --settings \"{}\"
|
||||
del \"{}\" >nul 2>&1
|
||||
del \"%~f0\" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Press any key to close...
|
||||
pause >nul
|
||||
)",
|
||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||
);
|
||||
|
||||
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
(None, Some("未安装或无法执行".to_string()))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ mod config;
|
||||
mod deeplink;
|
||||
mod env;
|
||||
mod failover;
|
||||
mod global_proxy;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
@@ -15,14 +14,12 @@ mod proxy;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod template;
|
||||
mod usage;
|
||||
|
||||
pub use config::*;
|
||||
pub use deeplink::*;
|
||||
pub use env::*;
|
||||
pub use failover::*;
|
||||
pub use global_proxy::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
@@ -33,5 +30,4 @@ pub use proxy::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use template::*;
|
||||
pub use usage::*;
|
||||
|
||||
@@ -59,24 +59,3 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
||||
}
|
||||
|
||||
/// 获取整流器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_rectifier_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::RectifierConfig, String> {
|
||||
state.db.get_rectifier_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置整流器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_rectifier_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::RectifierConfig,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.set_rectifier_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::database::lock_conn;
|
||||
use crate::error::AppError;
|
||||
use crate::services::{
|
||||
BatchInstallResult, ComponentDetail, InstalledComponent, PaginatedResult, TemplateComponent,
|
||||
TemplateRepo, TemplateService,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
|
||||
/// 刷新模板索引
|
||||
#[tauri::command]
|
||||
pub async fn refresh_template_index(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
// 使用 spawn_blocking 在后台线程中执行数据库操作
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.refresh_index(&conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取模板组件列表
|
||||
#[tauri::command]
|
||||
pub fn list_template_components(
|
||||
state: State<'_, AppState>,
|
||||
component_type: Option<String>,
|
||||
category: Option<String>,
|
||||
search: Option<String>,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
app_type: Option<String>,
|
||||
) -> Result<PaginatedResult<TemplateComponent>, AppError> {
|
||||
let (mut components, total) = state.db.list_components(
|
||||
component_type.as_deref(),
|
||||
category.as_deref(),
|
||||
search.as_deref(),
|
||||
page,
|
||||
page_size,
|
||||
)?;
|
||||
|
||||
// 填充 installed 字段
|
||||
if let Some(app) = &app_type {
|
||||
let installed_ids = state.db.get_installed_component_ids(app)?;
|
||||
for component in &mut components {
|
||||
if let Some(id) = component.id {
|
||||
component.installed = installed_ids.contains(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PaginatedResult {
|
||||
items: components,
|
||||
total: total as i64,
|
||||
page,
|
||||
page_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取组件详情
|
||||
#[tauri::command]
|
||||
pub async fn get_template_component(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
) -> Result<ComponentDetail, String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
let detail = tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.get_component(&conn, id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
||||
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
/// 安装组件
|
||||
#[tauri::command]
|
||||
pub async fn install_template_component(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
app_type: String,
|
||||
) -> Result<(), String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.install_component(&conn, id, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 卸载组件
|
||||
#[tauri::command]
|
||||
pub fn uninstall_template_component(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
app_type: String,
|
||||
) -> Result<(), AppError> {
|
||||
let service = TemplateService::new().map_err(|e| AppError::Config(e.to_string()))?;
|
||||
let conn = lock_conn!(state.db.conn);
|
||||
|
||||
service
|
||||
.uninstall_component(&conn, id, &app_type)
|
||||
.map_err(|e| AppError::Config(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量安装组件
|
||||
#[tauri::command]
|
||||
pub async fn batch_install_template_components(
|
||||
state: State<'_, AppState>,
|
||||
ids: Vec<i64>,
|
||||
app_type: String,
|
||||
) -> Result<BatchInstallResult, String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.batch_install(&conn, ids, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取模板仓库列表
|
||||
#[tauri::command]
|
||||
pub fn list_template_repos(state: State<'_, AppState>) -> Result<Vec<TemplateRepo>, AppError> {
|
||||
state.db.list_repos()
|
||||
}
|
||||
|
||||
/// 添加模板仓库
|
||||
#[tauri::command]
|
||||
pub fn add_template_repo(
|
||||
state: State<'_, AppState>,
|
||||
owner: String,
|
||||
name: String,
|
||||
branch: String,
|
||||
) -> Result<i64, AppError> {
|
||||
let repo = TemplateRepo::new(owner, name, branch);
|
||||
state.db.insert_repo(&repo)
|
||||
}
|
||||
|
||||
/// 删除模板仓库
|
||||
#[tauri::command]
|
||||
pub fn remove_template_repo(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
|
||||
state.db.delete_repo(id)
|
||||
}
|
||||
|
||||
/// 切换仓库启用状态
|
||||
#[tauri::command]
|
||||
pub fn toggle_template_repo(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
state.db.toggle_repo_enabled(id, enabled)
|
||||
}
|
||||
|
||||
/// 获取组件分类列表
|
||||
#[tauri::command]
|
||||
pub fn list_template_categories(
|
||||
state: State<'_, AppState>,
|
||||
component_type: Option<String>,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let conn = lock_conn!(state.db.conn);
|
||||
|
||||
// 构建查询语句
|
||||
let sql = if let Some(ct) = component_type {
|
||||
format!(
|
||||
"SELECT DISTINCT category FROM template_components WHERE component_type = '{ct}' AND category IS NOT NULL ORDER BY category"
|
||||
)
|
||||
} else {
|
||||
"SELECT DISTINCT category FROM template_components WHERE category IS NOT NULL ORDER BY category".to_string()
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let categories = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<Result<Vec<String>, _>>()?;
|
||||
|
||||
Ok(categories)
|
||||
}
|
||||
|
||||
/// 获取已安装组件列表
|
||||
#[tauri::command]
|
||||
pub fn list_installed_components(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<String>,
|
||||
component_type: Option<String>,
|
||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
||||
state
|
||||
.db
|
||||
.list_installed_components(app_type.as_deref(), component_type.as_deref())
|
||||
}
|
||||
|
||||
/// 预览组件内容
|
||||
#[tauri::command]
|
||||
pub async fn preview_component_content(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
) -> Result<String, String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.preview_content(&conn, id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
||||
}
|
||||
|
||||
/// 获取市场组合列表
|
||||
#[tauri::command]
|
||||
pub async fn list_marketplace_bundles(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<crate::services::MarketplaceBundle>, String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.fetch_marketplace_bundles(&conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
||||
}
|
||||
+6
-10
@@ -5,26 +5,22 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置目录路径
|
||||
pub fn get_claude_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
get_home_dir().join(".claude")
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".claude")
|
||||
}
|
||||
|
||||
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
|
||||
pub fn get_default_claude_mcp_path() -> PathBuf {
|
||||
get_home_dir().join(".claude.json")
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".claude.json")
|
||||
}
|
||||
|
||||
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
||||
|
||||
@@ -73,14 +73,11 @@ impl Database {
|
||||
params![
|
||||
server.id,
|
||||
server.name,
|
||||
serde_json::to_string(&server.server).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize server config: {e}"
|
||||
)))?,
|
||||
serde_json::to_string(&server.server).unwrap(),
|
||||
server.description,
|
||||
server.homepage,
|
||||
server.docs,
|
||||
serde_json::to_string(&server.tags)
|
||||
.map_err(|e| AppError::Database(format!("Failed to serialize tags: {e}")))?,
|
||||
serde_json::to_string(&server.tags).unwrap(),
|
||||
server.apps.claude,
|
||||
server.apps.codex,
|
||||
server.apps.gemini,
|
||||
|
||||
@@ -10,7 +10,6 @@ pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod template;
|
||||
pub mod universal_providers;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
|
||||
@@ -220,9 +220,7 @@ impl Database {
|
||||
WHERE id = ?13 AND app_type = ?14",
|
||||
params![
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config).map_err(|e| {
|
||||
AppError::Database(format!("Failed to serialize settings_config: {e}"))
|
||||
})?,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
provider.website_url,
|
||||
provider.category,
|
||||
provider.created_at,
|
||||
@@ -230,9 +228,7 @@ impl Database {
|
||||
provider.notes,
|
||||
provider.icon,
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize meta: {e}"
|
||||
)))?,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
provider.id,
|
||||
@@ -251,8 +247,7 @@ impl Database {
|
||||
provider.id,
|
||||
app_type,
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
.map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
provider.website_url,
|
||||
provider.category,
|
||||
provider.created_at,
|
||||
@@ -260,8 +255,7 @@ impl Database {
|
||||
provider.notes,
|
||||
provider.icon,
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone)
|
||||
.map_err(|e| AppError::Database(format!("Failed to serialize meta: {e}")))?,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
],
|
||||
@@ -330,9 +324,7 @@ impl Database {
|
||||
conn.execute(
|
||||
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![
|
||||
serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize settings_config: {e}"
|
||||
)))?,
|
||||
serde_json::to_string(settings_config).unwrap(),
|
||||
provider_id,
|
||||
app_type
|
||||
],
|
||||
|
||||
@@ -63,41 +63,6 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 全局出站代理 ---
|
||||
|
||||
/// 全局代理 URL 的存储键名
|
||||
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
|
||||
|
||||
/// 获取全局出站代理 URL
|
||||
///
|
||||
/// 返回 None 表示未配置或已清除代理(直连)
|
||||
/// 返回 Some(url) 表示已配置代理
|
||||
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
|
||||
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
|
||||
}
|
||||
|
||||
/// 设置全局出站代理 URL
|
||||
///
|
||||
/// - 传入非空字符串:启用代理
|
||||
/// - 传入空字符串或 None:清除代理设置(直连)
|
||||
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
|
||||
match url {
|
||||
Some(u) if !u.trim().is_empty() => {
|
||||
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
|
||||
}
|
||||
_ => {
|
||||
// 清除代理设置
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key = ?1",
|
||||
params![Self::GLOBAL_PROXY_URL_KEY],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||
|
||||
/// 获取指定应用的代理接管状态
|
||||
@@ -163,27 +128,4 @@ impl Database {
|
||||
log::info!("已清除所有代理接管状态");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- 整流器配置 ---
|
||||
|
||||
/// 获取整流器配置
|
||||
///
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
||||
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
||||
match self.get_setting("rectifier_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::RectifierConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新整流器配置
|
||||
pub fn set_rectifier_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::RectifierConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
|
||||
self.set_setting("rectifier_config", &json)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,595 +0,0 @@
|
||||
//! Template 数据访问对象
|
||||
//!
|
||||
//! 提供 Template Repos、Template Components 和 Installed Components 的 CRUD 操作。
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::services::template::{
|
||||
ComponentType, InstalledComponent, TemplateComponent, TemplateRepo,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
|
||||
impl Database {
|
||||
// ==================== TemplateRepo 相关 ====================
|
||||
|
||||
/// 插入模板仓库
|
||||
pub fn insert_repo(&self, repo: &TemplateRepo) -> Result<i64, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO template_repos (owner, name, branch, enabled, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![repo.owner, repo.name, repo.branch, repo.enabled, now, now],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入模板仓库失败: {e}")))?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// 获取单个模板仓库
|
||||
pub fn get_repo(&self, id: i64) -> Result<Option<TemplateRepo>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库失败: {e}")))?;
|
||||
|
||||
let repo = stmt
|
||||
.query_row(params![id], |row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get(4)?,
|
||||
created_at: row
|
||||
.get::<_, String>(5)
|
||||
.ok()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
updated_at: row
|
||||
.get::<_, String>(6)
|
||||
.ok()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
})
|
||||
})
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模板仓库失败: {e}")))?;
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// 获取所有模板仓库
|
||||
pub fn list_repos(&self) -> Result<Vec<TemplateRepo>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库列表失败: {e}")))?;
|
||||
|
||||
let repo_iter = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get(4)?,
|
||||
created_at: row
|
||||
.get::<_, String>(5)
|
||||
.ok()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
updated_at: row
|
||||
.get::<_, String>(6)
|
||||
.ok()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询模板仓库列表失败: {e}")))?;
|
||||
|
||||
let mut repos = Vec::new();
|
||||
for repo_res in repo_iter {
|
||||
repos.push(repo_res.map_err(|e| AppError::Database(format!("解析模板仓库失败: {e}")))?);
|
||||
}
|
||||
|
||||
Ok(repos)
|
||||
}
|
||||
|
||||
/// 更新模板仓库
|
||||
pub fn update_repo(&self, repo: &TemplateRepo) -> Result<(), AppError> {
|
||||
let repo_id = repo
|
||||
.id
|
||||
.ok_or_else(|| AppError::Database("仓库 ID 不能为空".to_string()))?;
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE template_repos
|
||||
SET owner = ?1, name = ?2, branch = ?3, enabled = ?4, updated_at = ?5
|
||||
WHERE id = ?6",
|
||||
params![
|
||||
repo.owner,
|
||||
repo.name,
|
||||
repo.branch,
|
||||
repo.enabled,
|
||||
now,
|
||||
repo_id
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模板仓库失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除模板仓库
|
||||
pub fn delete_repo(&self, id: i64) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
||||
.map_err(|e| AppError::Database(format!("删除模板仓库失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 切换仓库启用状态
|
||||
pub fn toggle_repo_enabled(&self, id: i64, enabled: bool) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE template_repos SET enabled = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![enabled, now, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("切换仓库启用状态失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== TemplateComponent 相关 ====================
|
||||
|
||||
/// 插入模板组件
|
||||
pub fn insert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO template_components
|
||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
component.repo_id,
|
||||
component.component_type.as_str(),
|
||||
component.category,
|
||||
component.name,
|
||||
component.path,
|
||||
component.description,
|
||||
component.content_hash,
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入模板组件失败: {e}")))?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// 获取单个模板组件
|
||||
pub fn get_component(&self, id: i64) -> Result<Option<TemplateComponent>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备查询模板组件失败: {e}")))?;
|
||||
|
||||
let component = stmt
|
||||
.query_row(params![id], |row| {
|
||||
let component_type_str: String = row.get(2)?;
|
||||
let component_type = ComponentType::from_str(&component_type_str)
|
||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type,
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false, // 需要单独查询
|
||||
})
|
||||
})
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模板组件失败: {e}")))?;
|
||||
|
||||
Ok(component)
|
||||
}
|
||||
|
||||
/// 获取组件列表(支持过滤和分页)
|
||||
pub fn list_components(
|
||||
&self,
|
||||
component_type: Option<&str>,
|
||||
category: Option<&str>,
|
||||
search: Option<&str>,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<(Vec<TemplateComponent>, u32), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 构建 WHERE 子句
|
||||
let mut where_clauses = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ct) = component_type {
|
||||
where_clauses.push("component_type = ?");
|
||||
params_vec.push(Box::new(ct.to_string()));
|
||||
}
|
||||
|
||||
if let Some(cat) = category {
|
||||
where_clauses.push("category = ?");
|
||||
params_vec.push(Box::new(cat.to_string()));
|
||||
}
|
||||
|
||||
if let Some(s) = search {
|
||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
||||
let pattern = format!("%{s}%");
|
||||
params_vec.push(Box::new(pattern.clone()));
|
||||
params_vec.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
// 查询总数
|
||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
||||
|
||||
let total: u32 = {
|
||||
let mut stmt = conn
|
||||
.prepare(&count_sql)
|
||||
.map_err(|e| AppError::Database(format!("准备统计组件数量失败: {e}")))?;
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
stmt.query_row(¶ms_refs[..], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(format!("统计组件数量失败: {e}")))?
|
||||
};
|
||||
|
||||
// 查询数据
|
||||
let offset = (page.saturating_sub(1)) * page_size;
|
||||
let query_sql = format!(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
{where_sql}
|
||||
ORDER BY name ASC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&query_sql)
|
||||
.map_err(|e| AppError::Database(format!("准备查询组件列表失败: {e}")))?;
|
||||
|
||||
params_vec.push(Box::new(page_size));
|
||||
params_vec.push(Box::new(offset));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let component_iter = stmt
|
||||
.query_map(¶ms_refs[..], |row| {
|
||||
let component_type_str: String = row.get(2)?;
|
||||
let component_type = ComponentType::from_str(&component_type_str)
|
||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type,
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false, // 需要单独查询
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询组件列表失败: {e}")))?;
|
||||
|
||||
let mut components = Vec::new();
|
||||
for component_res in component_iter {
|
||||
components
|
||||
.push(component_res.map_err(|e| AppError::Database(format!("解析组件失败: {e}")))?);
|
||||
}
|
||||
|
||||
Ok((components, total))
|
||||
}
|
||||
|
||||
/// 删除仓库的所有组件
|
||||
pub fn delete_components_by_repo(&self, repo_id: i64) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM template_components WHERE repo_id = ?1",
|
||||
params![repo_id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("删除仓库组件失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upsert 模板组件(根据 repo_id + component_type + path 判断是否已存在)
|
||||
pub fn upsert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO template_components
|
||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
ON CONFLICT(repo_id, component_type, path) DO UPDATE SET
|
||||
category = excluded.category,
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
content_hash = excluded.content_hash,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
component.repo_id,
|
||||
component.component_type.as_str(),
|
||||
component.category,
|
||||
component.name,
|
||||
component.path,
|
||||
component.description,
|
||||
component.content_hash,
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Upsert 模板组件失败: {e}")))?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
// ==================== InstalledComponent 相关 ====================
|
||||
|
||||
/// 插入已安装组件
|
||||
pub fn insert_installed(&self, installed: &InstalledComponent) -> Result<i64, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let installed_at = installed.installed_at.to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO installed_components
|
||||
(component_id, component_type, name, path, app_type, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
installed.component_id,
|
||||
installed.component_type.as_str(),
|
||||
installed.name,
|
||||
installed.path,
|
||||
installed.app_type,
|
||||
installed_at
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入已安装组件失败: {e}")))?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// 删除已安装组件
|
||||
pub fn delete_installed(
|
||||
&self,
|
||||
component_type: &str,
|
||||
path: &str,
|
||||
app_type: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM installed_components
|
||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
||||
params![component_type, path, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("删除已安装组件失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取已安装组件列表
|
||||
pub fn list_installed(
|
||||
&self,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type
|
||||
{
|
||||
(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
WHERE app_type = ?
|
||||
ORDER BY installed_at DESC"
|
||||
.to_string(),
|
||||
vec![Box::new(at.to_string())],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
ORDER BY installed_at DESC"
|
||||
.to_string(),
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let installed_iter = stmt
|
||||
.query_map(¶ms_refs[..], |row| {
|
||||
let component_type_str: String = row.get(2)?;
|
||||
let component_type = ComponentType::from_str(&component_type_str)
|
||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
let installed_at_str: String = row.get(6)?;
|
||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
Ok(InstalledComponent {
|
||||
id: Some(row.get(0)?),
|
||||
component_id: row.get(1)?,
|
||||
component_type,
|
||||
name: row.get(3)?,
|
||||
path: row.get(4)?,
|
||||
app_type: row.get(5)?,
|
||||
installed_at,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
||||
|
||||
let mut installed = Vec::new();
|
||||
for installed_res in installed_iter {
|
||||
installed.push(
|
||||
installed_res
|
||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
/// 获取已安装组件列表(支持 app_type 和 component_type 过滤)
|
||||
pub fn list_installed_components(
|
||||
&self,
|
||||
app_type: Option<&str>,
|
||||
component_type: Option<&str>,
|
||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 构建 WHERE 子句
|
||||
let mut where_clauses = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(at) = app_type {
|
||||
where_clauses.push("app_type = ?");
|
||||
params_vec.push(Box::new(at.to_string()));
|
||||
}
|
||||
|
||||
if let Some(ct) = component_type {
|
||||
where_clauses.push("component_type = ?");
|
||||
params_vec.push(Box::new(ct.to_string()));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
{where_sql}
|
||||
ORDER BY installed_at DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let installed_iter = stmt
|
||||
.query_map(¶ms_refs[..], |row| {
|
||||
let component_type_str: String = row.get(2)?;
|
||||
let component_type = ComponentType::from_str(&component_type_str)
|
||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
let installed_at_str: String = row.get(6)?;
|
||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
Ok(InstalledComponent {
|
||||
id: Some(row.get(0)?),
|
||||
component_id: row.get(1)?,
|
||||
component_type,
|
||||
name: row.get(3)?,
|
||||
path: row.get(4)?,
|
||||
app_type: row.get(5)?,
|
||||
installed_at,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
||||
|
||||
let mut installed = Vec::new();
|
||||
for installed_res in installed_iter {
|
||||
installed.push(
|
||||
installed_res
|
||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
/// 检查组件是否已安装
|
||||
pub fn is_installed(
|
||||
&self,
|
||||
component_type: &str,
|
||||
path: &str,
|
||||
app_type: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM installed_components
|
||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
||||
params![component_type, path, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("检查组件安装状态失败: {e}")))?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// 获取指定应用已安装的组件 ID 列表
|
||||
pub fn get_installed_component_ids(&self, app_type: &str) -> Result<Vec<i64>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT component_id FROM installed_components WHERE app_type = ?1 AND component_id IS NOT NULL",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
||||
|
||||
let ids: Vec<i64> = stmt
|
||||
.query_map(params![app_type], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
}
|
||||
@@ -302,90 +302,6 @@ impl Database {
|
||||
[],
|
||||
);
|
||||
|
||||
// 15. Template Repos 表 (模板仓库)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner, name)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 插入默认模板仓库
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
|
||||
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 16. Template Components 表 (模板组件)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_id INTEGER NOT NULL,
|
||||
component_type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
description TEXT,
|
||||
content_hash TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
|
||||
UNIQUE(repo_id, component_type, path)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 为 template_components 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_components_type
|
||||
ON template_components(component_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_components_category
|
||||
ON template_components(category)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 17. Installed Components 表 (已安装组件)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS installed_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
component_id INTEGER,
|
||||
component_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
|
||||
UNIQUE(component_type, path, app_type)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 为 installed_components 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
|
||||
ON installed_components(app_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -430,12 +346,6 @@ impl Database {
|
||||
Self::migrate_v2_to_v3(conn)?;
|
||||
Self::set_user_version(conn, 3)?;
|
||||
}
|
||||
// v3 -> v4: Claude Code Templates 市场功能(暂未启用)
|
||||
// 3 => {
|
||||
// log::info!("迁移数据库从 v3 到 v4(Claude Code Templates 市场功能)");
|
||||
// Self::migrate_v3_to_v4(conn)?;
|
||||
// Self::set_user_version(conn, 4)?;
|
||||
// }
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -876,97 +786,6 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v3 -> v4 迁移:添加 Claude Code Templates 功能相关表
|
||||
#[allow(dead_code)]
|
||||
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
|
||||
// 1. template_repos 表 - 存储模板仓库信息
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner, name)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 template_repos 表失败: {e}")))?;
|
||||
|
||||
// 2. template_components 表 - 存储从仓库中发现的模板组件
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_id INTEGER NOT NULL,
|
||||
component_type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
description TEXT,
|
||||
content_hash TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
|
||||
UNIQUE(repo_id, component_type, path)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 template_components 表失败: {e}")))?;
|
||||
|
||||
// 为 template_components 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_components_type
|
||||
ON template_components(component_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 template_components 类型索引失败: {e}")))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_components_category
|
||||
ON template_components(category)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 template_components 分类索引失败: {e}")))?;
|
||||
|
||||
// 3. installed_components 表 - 存储已安装的组件
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS installed_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
component_id INTEGER,
|
||||
component_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
|
||||
UNIQUE(component_type, path, app_type)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 installed_components 表失败: {e}")))?;
|
||||
|
||||
// 为 installed_components 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
|
||||
ON installed_components(app_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 installed_components 应用索引失败: {e}")))?;
|
||||
|
||||
// 4. 插入默认模板仓库
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
|
||||
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入默认模板仓库失败: {e}")))?;
|
||||
|
||||
log::info!("已创建 Claude Code Templates 相关表并插入默认仓库");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v2 -> v3 迁移:Skills 统一管理架构
|
||||
///
|
||||
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
||||
|
||||
@@ -518,7 +518,8 @@ fn model_pricing_is_seeded_on_init() {
|
||||
|
||||
assert!(
|
||||
count > 0,
|
||||
"模型定价数据应该在初始化时自动填充,实际数量: {count}"
|
||||
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
||||
count
|
||||
);
|
||||
|
||||
// 验证包含 Claude 模型
|
||||
@@ -531,7 +532,8 @@ fn model_pricing_is_seeded_on_init() {
|
||||
.expect("check claude");
|
||||
assert!(
|
||||
claude_count > 0,
|
||||
"应该包含 Claude 模型定价,实际数量: {claude_count}"
|
||||
"应该包含 Claude 模型定价,实际数量: {}",
|
||||
claude_count
|
||||
);
|
||||
|
||||
// 验证包含 GPT 模型
|
||||
@@ -544,7 +546,8 @@ fn model_pricing_is_seeded_on_init() {
|
||||
.expect("check gpt");
|
||||
assert!(
|
||||
gpt_count > 0,
|
||||
"应该包含 GPT 模型定价,实际数量: {gpt_count}"
|
||||
"应该包含 GPT 模型定价,实际数量: {}",
|
||||
gpt_count
|
||||
);
|
||||
|
||||
// 验证包含 Gemini 模型
|
||||
@@ -557,90 +560,7 @@ fn model_pricing_is_seeded_on_init() {
|
||||
.expect("check gemini");
|
||||
assert!(
|
||||
gemini_count > 0,
|
||||
"应该包含 Gemini 模型定价,实际数量: {gemini_count}"
|
||||
"应该包含 Gemini 模型定价,实际数量: {}",
|
||||
gemini_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_v2_to_v3_migration_creates_template_tables() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
// 创建 v2 schema(即当前完整的 schema)
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::set_user_version(&conn, 2).expect("set v2 version");
|
||||
|
||||
// 应用迁移到 v3
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("migrate to v3");
|
||||
|
||||
// 验证版本号已更新
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("read version"),
|
||||
3,
|
||||
"版本应该更新为 3"
|
||||
);
|
||||
|
||||
// 验证 template_repos 表存在且包含默认仓库
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM template_repos", [], |row| row.get(0))
|
||||
.expect("count template_repos");
|
||||
assert_eq!(count, 1, "应该有 1 个默认模板仓库");
|
||||
|
||||
let (owner, name, branch): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT owner, name, branch FROM template_repos WHERE id = 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.expect("read default repo");
|
||||
assert_eq!(owner, "yovinchen", "默认仓库 owner 应该是 yovinchen");
|
||||
assert_eq!(
|
||||
name, "claude-code-templates",
|
||||
"默认仓库 name 应该是 claude-code-templates"
|
||||
);
|
||||
assert_eq!(branch, "main", "默认仓库 branch 应该是 main");
|
||||
|
||||
// 验证 template_components 表存在
|
||||
assert!(
|
||||
Database::table_exists(&conn, "template_components").expect("check table"),
|
||||
"template_components 表应该存在"
|
||||
);
|
||||
|
||||
// 验证 installed_components 表存在
|
||||
assert!(
|
||||
Database::table_exists(&conn, "installed_components").expect("check table"),
|
||||
"installed_components 表应该存在"
|
||||
);
|
||||
|
||||
// 验证索引存在
|
||||
let index_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND (
|
||||
name = 'idx_template_components_type' OR
|
||||
name = 'idx_template_components_category' OR
|
||||
name = 'idx_installed_components_app'
|
||||
)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count indexes");
|
||||
assert_eq!(index_count, 3, "应该创建 3 个索引");
|
||||
|
||||
// 验证外键约束
|
||||
let fk_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('template_components')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count fk");
|
||||
assert_eq!(fk_count, 1, "template_components 应该有 1 个外键约束");
|
||||
|
||||
let fk_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('installed_components')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count fk");
|
||||
assert_eq!(fk_count, 1, "installed_components 应该有 1 个外键约束");
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
|
||||
/// Provider homepage URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
/// API endpoint/base URL (supports comma-separated multiple URLs)
|
||||
/// API endpoint/base URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
/// API key
|
||||
|
||||
@@ -101,13 +101,9 @@ fn parse_provider_deeplink(
|
||||
validate_url(hp, "homepage")?;
|
||||
}
|
||||
}
|
||||
// Validate each endpoint (supports comma-separated multiple URLs)
|
||||
if let Some(ref ep) = endpoint {
|
||||
for (i, url) in ep.split(',').enumerate() {
|
||||
let trimmed = url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
validate_url(trimmed, &format!("endpoint[{i}]"))?;
|
||||
}
|
||||
if !ep.is_empty() {
|
||||
validate_url(ep, "endpoint")?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
|
||||
}
|
||||
|
||||
// Step 1: Merge config file if provided (v3.8+)
|
||||
let mut merged_request = parse_and_merge_config(&request)?;
|
||||
let merged_request = parse_and_merge_config(&request)?;
|
||||
|
||||
// Extract required fields (now as Option)
|
||||
let app_str = merged_request
|
||||
.app
|
||||
.clone()
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
||||
|
||||
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
||||
@@ -51,29 +51,14 @@ pub fn import_provider_from_deeplink(
|
||||
));
|
||||
}
|
||||
|
||||
// Get endpoint: supports comma-separated multiple URLs (first is primary)
|
||||
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
||||
})?;
|
||||
|
||||
// Parse endpoints: split by comma, first is primary
|
||||
let all_endpoints: Vec<String> = endpoint_str
|
||||
.split(',')
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty())
|
||||
.collect();
|
||||
|
||||
let primary_endpoint = all_endpoints
|
||||
.first()
|
||||
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
|
||||
|
||||
// Auto-infer homepage from endpoint if not provided
|
||||
if merged_request
|
||||
.homepage
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
|
||||
if endpoint.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Endpoint cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
||||
@@ -88,11 +73,11 @@ pub fn import_provider_from_deeplink(
|
||||
|
||||
let name = merged_request
|
||||
.name
|
||||
.clone()
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
||||
|
||||
// Parse app type
|
||||
let app_type = AppType::from_str(&app_str)
|
||||
let app_type = AppType::from_str(app_str)
|
||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||||
|
||||
// Build provider configuration based on app type
|
||||
@@ -112,21 +97,6 @@ pub fn import_provider_from_deeplink(
|
||||
// Use ProviderService to add the provider
|
||||
ProviderService::add(state, app_type.clone(), provider)?;
|
||||
|
||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||
for ep in all_endpoints.iter().skip(1) {
|
||||
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||||
if !normalized.is_empty() {
|
||||
if let Err(e) = ProviderService::add_custom_endpoint(
|
||||
state,
|
||||
app_type.clone(),
|
||||
&provider_id,
|
||||
normalized.clone(),
|
||||
) {
|
||||
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If enabled=true, set as current provider
|
||||
if merged_request.enabled.unwrap_or(false) {
|
||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||
@@ -168,16 +138,6 @@ pub(crate) fn build_provider_from_request(
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
/// Get primary endpoint from request (first one if comma-separated)
|
||||
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
||||
request
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.and_then(|ep| ep.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build provider meta with usage script configuration
|
||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||
// Check if any usage script fields are provided
|
||||
@@ -205,7 +165,6 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||
|
||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||
// Note: use primary endpoint only (first one if comma-separated)
|
||||
let usage_script = UsageScript {
|
||||
enabled,
|
||||
language: "javascript".to_string(),
|
||||
@@ -215,14 +174,10 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
.usage_api_key
|
||||
.clone()
|
||||
.or_else(|| request.api_key.clone()),
|
||||
base_url: request.usage_base_url.clone().or_else(|| {
|
||||
let primary = get_primary_endpoint(request);
|
||||
if primary.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary)
|
||||
}
|
||||
}),
|
||||
base_url: request
|
||||
.usage_base_url
|
||||
.clone()
|
||||
.or_else(|| request.endpoint.clone()),
|
||||
access_token: request.usage_access_token.clone(),
|
||||
user_id: request.usage_user_id.clone(),
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
@@ -243,7 +198,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
);
|
||||
env.insert(
|
||||
"ANTHROPIC_BASE_URL".to_string(),
|
||||
json!(get_primary_endpoint(request)),
|
||||
json!(request.endpoint.clone().unwrap_or_default()),
|
||||
);
|
||||
|
||||
// Add default model if provided
|
||||
@@ -316,8 +271,11 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
.unwrap_or("gpt-5-codex")
|
||||
.to_string();
|
||||
|
||||
// Endpoint: normalize trailing slashes (use primary endpoint only)
|
||||
let endpoint = get_primary_endpoint(request)
|
||||
// Endpoint: normalize trailing slashes
|
||||
let endpoint = request
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
@@ -351,7 +309,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||
env.insert(
|
||||
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||
json!(get_primary_endpoint(request)),
|
||||
json!(request.endpoint),
|
||||
);
|
||||
|
||||
// Add model if provided
|
||||
@@ -451,26 +409,27 @@ fn merge_claude_config(
|
||||
})?;
|
||||
|
||||
// Auto-fill API key if not provided in URL
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
||||
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
|
||||
request.api_key = Some(token.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill endpoint if not provided in URL
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
||||
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint if not provided
|
||||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://anthropic.com".to_string());
|
||||
}
|
||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
||||
&& request.endpoint.is_some()
|
||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
||||
{
|
||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://anthropic.com".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,7 +468,7 @@ fn merge_codex_config(
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Auto-fill API key from auth.OPENAI_API_KEY
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
||||
if let Some(api_key) = config
|
||||
.get("auth")
|
||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||
@@ -524,7 +483,7 @@ fn merge_codex_config(
|
||||
// Parse TOML config string to extract base_url and model
|
||||
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
|
||||
// Extract base_url from model_providers section
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
||||
if let Some(base_url) = extract_codex_base_url(&toml_value) {
|
||||
request.endpoint = Some(base_url);
|
||||
}
|
||||
@@ -540,12 +499,13 @@ fn merge_codex_config(
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint
|
||||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://openai.com".to_string());
|
||||
}
|
||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
||||
&& request.endpoint.is_some()
|
||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
||||
{
|
||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://openai.com".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,18 +518,14 @@ fn merge_gemini_config(
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Gemini uses flat env structure
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
||||
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||
request.api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(base_url) = config
|
||||
.get("GOOGLE_GEMINI_BASE_URL")
|
||||
.or_else(|| config.get("GEMINI_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
||||
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
@@ -582,12 +538,13 @@ fn merge_gemini_config(
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint
|
||||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://ai.google.dev".to_string());
|
||||
}
|
||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
||||
&& request.endpoint.is_some()
|
||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
||||
{
|
||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://ai.google.dev".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -365,7 +365,8 @@ fn test_parse_prompt_deeplink() {
|
||||
let content = "Hello World";
|
||||
let content_b64 = BASE64_STANDARD.encode(content);
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={content_b64}&description=desc&enabled=true"
|
||||
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true",
|
||||
content_b64
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).unwrap();
|
||||
@@ -382,7 +383,8 @@ fn test_parse_mcp_deeplink() {
|
||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||
let config_b64 = BASE64_STANDARD.encode(config);
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={config_b64}&enabled=true"
|
||||
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true",
|
||||
config_b64
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).unwrap();
|
||||
@@ -402,57 +404,3 @@ fn test_parse_skill_deeplink() {
|
||||
assert_eq!(request.directory.unwrap(), "skills");
|
||||
assert_eq!(request.branch.unwrap(), "dev");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Multiple Endpoints Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_endpoints_comma_separated() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert!(request.endpoint.is_some());
|
||||
let endpoint = request.endpoint.unwrap();
|
||||
// Should contain all endpoints comma-separated
|
||||
assert!(endpoint.contains("https://api1.example.com"));
|
||||
assert!(endpoint.contains("https://api2.example.com"));
|
||||
assert!(endpoint.contains("https://api3.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_endpoint_backward_compatible() {
|
||||
// Old format with single endpoint should still work
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.endpoint,
|
||||
Some("https://api.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_endpoints_with_spaces_trimmed() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
// Validation should pass (spaces are trimmed during validation)
|
||||
assert!(request.endpoint.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_homepage_from_endpoint_without_homepage() {
|
||||
// Test that homepage is auto-inferred from endpoint when not provided
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://cubence.com"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,21 +5,15 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||
pub fn get_gemini_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
get_home_dir().join(".gemini")
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".gemini")
|
||||
}
|
||||
|
||||
/// 获取 Gemini .env 文件路径
|
||||
|
||||
@@ -134,33 +134,6 @@ pub fn set_mcp_servers_map(
|
||||
obj.remove("homepage");
|
||||
obj.remove("docs");
|
||||
|
||||
// Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec
|
||||
// Gemini CLI 只支持 timeout(单位 ms)
|
||||
// 默认值:startup=10s, tool=60s
|
||||
const DEFAULT_STARTUP_MS: u64 = 10_000;
|
||||
const DEFAULT_TOOL_MS: u64 = 60_000;
|
||||
|
||||
let extract_timeout =
|
||||
|obj: &mut Map<String, Value>, key: &str, multiplier: u64| -> Option<u64> {
|
||||
obj.remove(key).and_then(|val| {
|
||||
val.as_u64()
|
||||
.map(|n| n * multiplier)
|
||||
.or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))
|
||||
})
|
||||
};
|
||||
|
||||
// 分别收集 startup 和 tool timeout,未设置时使用默认值
|
||||
let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000)
|
||||
.or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1))
|
||||
.unwrap_or(DEFAULT_STARTUP_MS);
|
||||
let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000)
|
||||
.or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1))
|
||||
.unwrap_or(DEFAULT_TOOL_MS);
|
||||
|
||||
// 取最大值作为 Gemini timeout
|
||||
let final_timeout = startup_ms.max(tool_ms);
|
||||
obj.insert("timeout".to_string(), Value::Number(final_timeout.into()));
|
||||
|
||||
out.insert(id.clone(), Value::Object(obj));
|
||||
}
|
||||
|
||||
|
||||
+9
-125
@@ -13,7 +13,6 @@ mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod panic_hook;
|
||||
mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
@@ -27,7 +26,6 @@ mod usage_script;
|
||||
|
||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||
pub use commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||
pub use database::Database;
|
||||
@@ -56,37 +54,6 @@ use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
fn redact_url_for_log(url_str: &str) -> String {
|
||||
match url::Url::parse(url_str) {
|
||||
Ok(url) => {
|
||||
let mut output = format!("{}://", url.scheme());
|
||||
if let Some(host) = url.host_str() {
|
||||
output.push_str(host);
|
||||
}
|
||||
output.push_str(url.path());
|
||||
|
||||
let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
|
||||
if !keys.is_empty() {
|
||||
output.push_str("?[keys:");
|
||||
output.push_str(&keys.join(","));
|
||||
output.push(']');
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
Err(_) => {
|
||||
let base = url_str.split('#').next().unwrap_or(url_str);
|
||||
match base.split_once('?') {
|
||||
Some((prefix, _)) => format!("{prefix}?[redacted]"),
|
||||
None => base.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一处理 ccswitch:// 深链接 URL
|
||||
///
|
||||
/// - 解析 URL
|
||||
@@ -102,9 +69,7 @@ fn handle_deeplink_url(
|
||||
return false;
|
||||
}
|
||||
|
||||
let redacted_url = redact_url_for_log(url_str);
|
||||
log::info!("✓ Deep link URL detected from {source}: {redacted_url}");
|
||||
log::debug!("Deep link URL (raw) from {source}: {url_str}");
|
||||
log::info!("✓ Deep link URL detected from {source}: {url_str}");
|
||||
|
||||
match crate::deeplink::parse_deeplink_url(url_str) {
|
||||
Ok(request) => {
|
||||
@@ -185,18 +150,15 @@ fn macos_tray_icon() -> Option<Image<'static>> {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// 设置 panic hook,在应用崩溃时记录日志到 <app_config_dir>/crash.log(默认 ~/.cc-switch/crash.log)
|
||||
panic_hook::setup_panic_hook();
|
||||
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
log::info!("=== Single Instance Callback Triggered ===");
|
||||
log::debug!("Args count: {}", args.len());
|
||||
log::info!("Args count: {}", args.len());
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
||||
log::info!(" arg[{i}]: {arg}");
|
||||
}
|
||||
|
||||
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||
@@ -250,10 +212,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.setup(|app| {
|
||||
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
|
||||
|
||||
// 注册 Updater 插件(桌面端)
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
@@ -265,35 +223,18 @@ pub fn run() {
|
||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||
}
|
||||
}
|
||||
// 初始化日志(Debug 和 Release 模式都启用 Info 级别)
|
||||
// 日志同时输出到控制台和文件(<app_config_dir>/logs/;若设置了覆盖则使用覆盖目录)
|
||||
{
|
||||
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
||||
|
||||
let log_dir = panic_hook::get_log_dir();
|
||||
|
||||
// 初始化日志
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.targets([
|
||||
// 输出到控制台
|
||||
Target::new(TargetKind::Stdout),
|
||||
// 输出到日志文件
|
||||
Target::new(TargetKind::Folder {
|
||||
path: log_dir,
|
||||
file_name: Some("cc-switch".into()),
|
||||
}),
|
||||
])
|
||||
.rotation_strategy(RotationStrategy::KeepAll)
|
||||
.max_file_size(5_000_000) // 5MB 单文件上限
|
||||
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||
.build(),
|
||||
)?;
|
||||
|
||||
// 清理旧日志文件,只保留最近 2 个
|
||||
panic_hook::cleanup_old_logs();
|
||||
}
|
||||
|
||||
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
|
||||
// 初始化数据库
|
||||
let app_config_dir = crate::config::get_app_config_dir();
|
||||
let db_path = app_config_dir.join("cc-switch.db");
|
||||
@@ -588,7 +529,7 @@ pub fn run() {
|
||||
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
let url_str = url.as_str();
|
||||
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
||||
log::info!(" URL[{i}]: {url_str}");
|
||||
|
||||
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||
break; // Process only first ccswitch:// URL
|
||||
@@ -644,37 +585,6 @@ pub fn run() {
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
|
||||
// 初始化全局出站代理 HTTP 客户端
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
let proxy_url = db.get_global_proxy_url().ok().flatten();
|
||||
|
||||
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
|
||||
);
|
||||
|
||||
// 清除无效的代理配置
|
||||
if proxy_url.is_some() {
|
||||
log::warn!(
|
||||
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
|
||||
);
|
||||
if let Err(clear_err) = db.set_global_proxy_url(None) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用直连模式重新初始化
|
||||
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 异常退出恢复 + 代理状态自动恢复
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -734,8 +644,6 @@ pub fn run() {
|
||||
commands::read_live_provider_settings,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::get_rectifier_config,
|
||||
commands::set_rectifier_config,
|
||||
commands::restart_app,
|
||||
commands::check_for_updates,
|
||||
commands::is_portable_mode,
|
||||
@@ -866,36 +774,12 @@ pub fn run() {
|
||||
commands::get_stream_check_config,
|
||||
commands::save_stream_check_config,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
commands::open_provider_terminal,
|
||||
// Universal Provider management
|
||||
commands::get_universal_providers,
|
||||
commands::get_universal_provider,
|
||||
commands::upsert_universal_provider,
|
||||
commands::delete_universal_provider,
|
||||
commands::sync_universal_provider,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
commands::test_proxy_url,
|
||||
commands::get_upstream_proxy_status,
|
||||
commands::scan_local_proxies,
|
||||
|
||||
// Template management
|
||||
commands::refresh_template_index,
|
||||
commands::list_template_components,
|
||||
commands::get_template_component,
|
||||
commands::install_template_component,
|
||||
commands::uninstall_template_component,
|
||||
commands::batch_install_template_components,
|
||||
commands::list_template_repos,
|
||||
commands::add_template_repo,
|
||||
commands::remove_template_repo,
|
||||
commands::toggle_template_repo,
|
||||
commands::list_template_categories,
|
||||
commands::list_installed_components,
|
||||
commands::preview_component_content,
|
||||
commands::list_marketplace_bundles,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
//! Panic Hook 模块
|
||||
//!
|
||||
//! 在应用崩溃时捕获 panic 信息并记录到 `<app_config_dir>/crash.log` 文件中(默认 `~/.cc-switch/crash.log`)。
|
||||
//! 便于用户和开发者诊断闪退问题。
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::panic;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 应用版本号(从 Cargo.toml 读取)
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// 日志文件保留数量
|
||||
const LOG_FILES_TO_KEEP: usize = 2;
|
||||
|
||||
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
pub fn init_app_config_dir(dir: PathBuf) {
|
||||
let _ = APP_CONFIG_DIR.set(dir);
|
||||
}
|
||||
|
||||
/// 获取默认应用配置目录(不会 panic)
|
||||
fn default_app_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".cc-switch")
|
||||
}
|
||||
|
||||
/// 获取应用配置目录(优先使用初始化时写入的值;不会 panic)
|
||||
fn get_app_config_dir() -> PathBuf {
|
||||
APP_CONFIG_DIR
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(default_app_config_dir)
|
||||
}
|
||||
|
||||
/// 获取崩溃日志文件路径
|
||||
fn get_crash_log_path() -> PathBuf {
|
||||
get_app_config_dir().join("crash.log")
|
||||
}
|
||||
|
||||
/// 获取日志目录路径
|
||||
pub fn get_log_dir() -> PathBuf {
|
||||
get_app_config_dir().join("logs")
|
||||
}
|
||||
|
||||
/// 清理旧日志文件,只保留最近 N 个
|
||||
///
|
||||
/// 在应用启动时调用,确保日志文件不会无限增长。
|
||||
pub fn cleanup_old_logs() {
|
||||
let log_dir = get_log_dir();
|
||||
|
||||
if !log_dir.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取目录中的所有 .log 文件
|
||||
let mut log_files: Vec<_> = match std::fs::read_dir(&log_dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().map(|ext| ext == "log").unwrap_or(false))
|
||||
.collect(),
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// 如果文件数量不超过保留数量,无需清理
|
||||
if log_files.len() <= LOG_FILES_TO_KEEP {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按修改时间排序(最新的在前)
|
||||
log_files.sort_by(|a, b| {
|
||||
let time_a = a.metadata().and_then(|m| m.modified()).ok();
|
||||
let time_b = b.metadata().and_then(|m| m.modified()).ok();
|
||||
time_b.cmp(&time_a) // 降序
|
||||
});
|
||||
|
||||
// 删除多余的旧文件
|
||||
for old_file in log_files.into_iter().skip(LOG_FILES_TO_KEEP) {
|
||||
if let Err(e) = std::fs::remove_file(&old_file) {
|
||||
log::warn!("清理旧日志文件失败 {}: {e}", old_file.display());
|
||||
} else {
|
||||
log::info!("已清理旧日志文件: {}", old_file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全获取环境信息(不会 panic)
|
||||
fn get_system_info() -> String {
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
let family = std::env::consts::FAMILY;
|
||||
|
||||
// 安全获取当前工作目录
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
|
||||
// 安全获取当前线程信息
|
||||
let thread = std::thread::current();
|
||||
let thread_name = thread.name().unwrap_or("unnamed");
|
||||
let thread_id = format!("{:?}", thread.id());
|
||||
|
||||
format!(
|
||||
"OS: {os} ({family})\n\
|
||||
Arch: {arch}\n\
|
||||
App Version: {APP_VERSION}\n\
|
||||
Working Dir: {cwd}\n\
|
||||
Thread: {thread_name} (ID: {thread_id})"
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置 panic hook,捕获崩溃信息并写入日志文件
|
||||
///
|
||||
/// 在应用启动时调用此函数,确保任何 panic 都会被记录。
|
||||
/// 日志格式包含:
|
||||
/// - 时间戳
|
||||
/// - 应用版本和系统信息
|
||||
/// - Panic 信息
|
||||
/// - 发生位置(文件:行号)
|
||||
/// - Backtrace(完整调用栈)
|
||||
pub fn setup_panic_hook() {
|
||||
// 启用 backtrace(确保 release 模式也能捕获)
|
||||
if std::env::var("RUST_BACKTRACE").is_err() {
|
||||
std::env::set_var("RUST_BACKTRACE", "1");
|
||||
}
|
||||
|
||||
let default_hook = panic::take_hook();
|
||||
|
||||
panic::set_hook(Box::new(move |panic_info| {
|
||||
let log_path = get_crash_log_path();
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
// 构建崩溃信息(使用 catch_unwind 保护时间格式化,避免嵌套 panic)
|
||||
let timestamp = std::panic::catch_unwind(|| {
|
||||
chrono::Local::now()
|
||||
.format("%Y-%m-%d %H:%M:%S%.3f")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
// chrono panic 时回退到 unix timestamp
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| format!("unix:{}.{:03}", d.as_secs(), d.subsec_millis()))
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
});
|
||||
|
||||
// 获取系统信息
|
||||
let system_info = std::panic::catch_unwind(get_system_info)
|
||||
.unwrap_or_else(|_| "Failed to get system info".to_string());
|
||||
|
||||
// 获取 panic 消息(尝试多种方式提取)
|
||||
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
// 尝试使用 Display trait
|
||||
format!("{panic_info}")
|
||||
};
|
||||
|
||||
// 获取位置信息
|
||||
let location = if let Some(loc) = panic_info.location() {
|
||||
format!(
|
||||
"File: {}\n Line: {}\n Column: {}",
|
||||
loc.file(),
|
||||
loc.line(),
|
||||
loc.column()
|
||||
)
|
||||
} else {
|
||||
"Unknown location".to_string()
|
||||
};
|
||||
|
||||
// 捕获 backtrace(完整调用栈)
|
||||
let backtrace = std::backtrace::Backtrace::force_capture();
|
||||
let backtrace_str = format!("{backtrace}");
|
||||
|
||||
// 格式化日志条目
|
||||
let separator = "=".repeat(80);
|
||||
let sub_separator = "-".repeat(40);
|
||||
let crash_entry = format!(
|
||||
r#"
|
||||
{separator}
|
||||
[CRASH REPORT] {timestamp}
|
||||
{separator}
|
||||
|
||||
{sub_separator}
|
||||
System Information
|
||||
{sub_separator}
|
||||
{system_info}
|
||||
|
||||
{sub_separator}
|
||||
Error Details
|
||||
{sub_separator}
|
||||
Message: {message}
|
||||
|
||||
Location: {location}
|
||||
|
||||
{sub_separator}
|
||||
Stack Trace (Backtrace)
|
||||
{sub_separator}
|
||||
{backtrace_str}
|
||||
|
||||
{separator}
|
||||
"#
|
||||
);
|
||||
|
||||
// 写入文件(追加模式)
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = file.write_all(crash_entry.as_bytes());
|
||||
let _ = file.flush();
|
||||
|
||||
// 记录日志文件位置到 stderr
|
||||
eprintln!("\n[CC-Switch] Crash log saved to: {}", log_path.display());
|
||||
}
|
||||
|
||||
// 同时输出到 stderr(便于开发调试)
|
||||
eprintln!("{crash_entry}");
|
||||
|
||||
// 调用默认 hook
|
||||
default_hook(panic_info);
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_crash_log_path() {
|
||||
let path = get_crash_log_path();
|
||||
assert!(path.ends_with("crash.log"));
|
||||
assert!(path.to_string_lossy().contains(".cc-switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_info() {
|
||||
let info = get_system_info();
|
||||
assert!(info.contains("OS:"));
|
||||
assert!(info.contains("Arch:"));
|
||||
assert!(info.contains("App Version:"));
|
||||
}
|
||||
}
|
||||
@@ -147,9 +147,6 @@ pub struct ProviderMeta {
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
/// 请求地址管理:测速后自动选择最佳端点
|
||||
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint_auto_select: Option<bool>,
|
||||
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
||||
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
||||
pub is_partner: Option<bool>,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||
|
||||
use super::log_codes::cb as log_cb;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -107,6 +106,7 @@ impl CircuitBreaker {
|
||||
/// 更新熔断器配置(热更新,不重置状态)
|
||||
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
|
||||
*self.config.write().await = new_config;
|
||||
log::debug!("Circuit breaker config updated");
|
||||
}
|
||||
|
||||
/// 判断当前 Provider 是否“可被纳入候选链路”
|
||||
@@ -128,8 +128,7 @@ impl CircuitBreaker {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!(
|
||||
"[{}] 熔断器 Open → HalfOpen (超时恢复)",
|
||||
log_cb::OPEN_TO_HALF_OPEN
|
||||
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
||||
);
|
||||
self.transition_to_half_open().await;
|
||||
return true;
|
||||
@@ -156,8 +155,7 @@ impl CircuitBreaker {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!(
|
||||
"[{}] 熔断器 Open → HalfOpen (超时恢复)",
|
||||
log_cb::OPEN_TO_HALF_OPEN
|
||||
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
||||
);
|
||||
self.transition_to_half_open().await;
|
||||
|
||||
@@ -199,17 +197,25 @@ impl CircuitBreaker {
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if state == CircuitState::HalfOpen {
|
||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
if successes >= config.success_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!(
|
||||
"[{}] 熔断器 HalfOpen → Closed (恢复正常)",
|
||||
log_cb::HALF_OPEN_TO_CLOSED
|
||||
match state {
|
||||
CircuitState::HalfOpen => {
|
||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
log::debug!(
|
||||
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
|
||||
successes,
|
||||
config.success_threshold
|
||||
);
|
||||
self.transition_to_closed().await;
|
||||
|
||||
if successes >= config.success_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
|
||||
self.transition_to_closed().await;
|
||||
}
|
||||
}
|
||||
CircuitState::Closed => {
|
||||
log::debug!("Circuit breaker Closed: request succeeded");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,14 +236,18 @@ impl CircuitBreaker {
|
||||
// 重置成功计数
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
|
||||
log::debug!(
|
||||
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
|
||||
state,
|
||||
failures,
|
||||
config.failure_threshold
|
||||
);
|
||||
|
||||
// 检查是否应该打开熔断器
|
||||
match state {
|
||||
CircuitState::HalfOpen => {
|
||||
// HalfOpen 状态下失败,立即转为 Open
|
||||
log::warn!(
|
||||
"[{}] 熔断器 HalfOpen 探测失败 → Open",
|
||||
log_cb::HALF_OPEN_PROBE_FAILED
|
||||
);
|
||||
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
|
||||
drop(config);
|
||||
self.transition_to_open().await;
|
||||
}
|
||||
@@ -245,8 +255,9 @@ impl CircuitBreaker {
|
||||
// 检查连续失败次数
|
||||
if failures >= config.failure_threshold {
|
||||
log::warn!(
|
||||
"[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
|
||||
log_cb::TRIGGERED_FAILURES
|
||||
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
|
||||
failures,
|
||||
config.failure_threshold
|
||||
);
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open().await;
|
||||
@@ -257,12 +268,18 @@ impl CircuitBreaker {
|
||||
|
||||
if total >= config.min_requests {
|
||||
let error_rate = failed as f64 / total as f64;
|
||||
log::debug!(
|
||||
"Circuit breaker error rate: {:.2}% ({}/{} requests)",
|
||||
error_rate * 100.0,
|
||||
failed,
|
||||
total
|
||||
);
|
||||
|
||||
if error_rate >= config.error_rate_threshold {
|
||||
log::warn!(
|
||||
"[{}] 熔断器触发: 错误率 {:.1}% → Open",
|
||||
log_cb::TRIGGERED_ERROR_RATE,
|
||||
error_rate * 100.0
|
||||
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)",
|
||||
error_rate * 100.0,
|
||||
config.error_rate_threshold * 100.0
|
||||
);
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open().await;
|
||||
@@ -295,16 +312,22 @@ impl CircuitBreaker {
|
||||
/// 重置熔断器(手动恢复)
|
||||
#[allow(dead_code)]
|
||||
pub async fn reset(&self) {
|
||||
log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET);
|
||||
log::info!("Circuit breaker manually reset to Closed state");
|
||||
self.transition_to_closed().await;
|
||||
}
|
||||
|
||||
fn allow_half_open_probe(&self) -> AllowResult {
|
||||
// 半开状态限流:只允许有限请求通过进行探测
|
||||
// 默认最多允许 1 个请求(可在配置中扩展)
|
||||
let max_half_open_requests = 1u32;
|
||||
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if current < max_half_open_requests {
|
||||
log::debug!(
|
||||
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
|
||||
current + 1,
|
||||
max_half_open_requests
|
||||
);
|
||||
AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: true,
|
||||
@@ -312,6 +335,9 @@ impl CircuitBreaker {
|
||||
} else {
|
||||
// 超过限额,回退计数,拒绝请求
|
||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
||||
log::debug!(
|
||||
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
|
||||
);
|
||||
AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
@@ -319,14 +345,12 @@ impl CircuitBreaker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计
|
||||
///
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub fn release_half_open_permit(&self) {
|
||||
fn release_half_open_permit(&self) {
|
||||
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if current == 0 {
|
||||
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
|
||||
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,6 @@ pub enum ProxyError {
|
||||
#[error("地址绑定失败: {0}")]
|
||||
BindFailed(String),
|
||||
|
||||
#[error("停止超时")]
|
||||
StopTimeout,
|
||||
|
||||
#[error("停止失败: {0}")]
|
||||
StopFailed(String),
|
||||
|
||||
#[error("请求转发失败: {0}")]
|
||||
ForwardFailed(String),
|
||||
|
||||
@@ -119,12 +113,6 @@ impl IntoResponse for ProxyError {
|
||||
ProxyError::BindFailed(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::StopTimeout => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::StopFailed(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
|
||||
ProxyError::NoAvailableProvider => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
|
||||
@@ -86,17 +86,17 @@ impl FailoverSwitchManager {
|
||||
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(config) => config.enabled,
|
||||
Err(e) => {
|
||||
log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换");
|
||||
log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
if !app_enabled {
|
||||
log::debug!("[Failover] {app_type} 未启用代理,跳过切换");
|
||||
log::info!("[Failover] {app_type} 未被代理接管(enabled=false),跳过切换");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
|
||||
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
|
||||
|
||||
// 1. 更新数据库 is_current
|
||||
self.db.set_current_provider(app_type, provider_id)?;
|
||||
@@ -117,7 +117,7 @@ impl FailoverSwitchManager {
|
||||
.update_live_backup_from_provider(app_type, &provider)
|
||||
.await
|
||||
{
|
||||
log::warn!("[FO-003] Live 备份更新失败: {e}");
|
||||
log::warn!("[Failover] 更新 Live 备份失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +138,12 @@ impl FailoverSwitchManager {
|
||||
"source": "failover" // 标识来源是故障转移
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
log::error!("[Failover] 发射事件失败: {e}");
|
||||
log::error!("[Failover] 发射供应商切换事件失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ use super::{
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||
types::{ProxyStatus, RectifierConfig},
|
||||
providers::{get_adapter, ProviderAdapter},
|
||||
types::ProxyStatus,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use reqwest::Response;
|
||||
use reqwest::{Client, Response};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||
@@ -81,6 +81,8 @@ pub struct ForwardError {
|
||||
}
|
||||
|
||||
pub struct RequestForwarder {
|
||||
client: Option<Client>,
|
||||
client_init_error: Option<String>,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
router: Arc<ProviderRouter>,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
@@ -91,10 +93,6 @@ pub struct RequestForwarder {
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||
current_provider_id_at_start: String,
|
||||
/// 整流器配置
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
@@ -109,17 +107,52 @@ impl RequestForwarder {
|
||||
current_provider_id_at_start: String,
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
) -> Self {
|
||||
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
|
||||
// 参考 Claude Code Hub 的 undici 全局超时设计
|
||||
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
let timeout_secs = if non_streaming_timeout > 0 {
|
||||
non_streaming_timeout
|
||||
} else {
|
||||
GLOBAL_TIMEOUT_SECS
|
||||
};
|
||||
|
||||
// 注意:这里不能用 expect/unwrap。
|
||||
// release 配置为 panic=abort,一旦 build 失败会导致整个应用闪退。
|
||||
// 常见原因:用户环境变量里存在不合法/不支持的代理(HTTP(S)_PROXY/ALL_PROXY 等)。
|
||||
let (client, client_init_error) = match Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => (Some(client), None),
|
||||
Err(e) => {
|
||||
// 降级:忽略系统/环境代理,避免因代理配置问题导致整个应用崩溃
|
||||
match Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.no_proxy()
|
||||
.build()
|
||||
{
|
||||
Ok(client) => (Some(client), Some(e.to_string())),
|
||||
Err(fallback_err) => (
|
||||
None,
|
||||
Some(format!(
|
||||
"Failed to create HTTP client: {e}; no_proxy fallback failed: {fallback_err}"
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
client,
|
||||
client_init_error,
|
||||
router,
|
||||
status,
|
||||
current_providers,
|
||||
failover_manager,
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +168,7 @@ impl RequestForwarder {
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
endpoint: &str,
|
||||
mut body: Value,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
@@ -154,9 +187,6 @@ impl RequestForwarder {
|
||||
let mut last_provider = None;
|
||||
let mut attempted_providers = 0usize;
|
||||
|
||||
// 整流器重试标记:确保整流最多触发一次
|
||||
let mut rectifier_retried = false;
|
||||
|
||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||
let bypass_circuit_breaker = providers.len() == 1;
|
||||
|
||||
@@ -251,205 +281,6 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
|
||||
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
|
||||
let is_anthropic_provider = matches!(
|
||||
provider_type,
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||
);
|
||||
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
if should_rectify_thinking_signature(
|
||||
error_message.as_deref(),
|
||||
&self.rectifier_config,
|
||||
) {
|
||||
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||
if rectifier_retried {
|
||||
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
|
||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// 首次触发:整流请求体
|
||||
let rectified = rectify_anthropic_request(&mut body);
|
||||
|
||||
// 整流未生效:直接返回错误(不可重试客户端错误)
|
||||
if !rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
||||
);
|
||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||
app_type_str,
|
||||
rectified.removed_thinking_blocks,
|
||||
rectified.removed_redacted_thinking_blocks,
|
||||
rectified.removed_signature_fields
|
||||
);
|
||||
|
||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
current_providers.insert(
|
||||
app_type_str.to_string(),
|
||||
(provider.id.clone(), provider.name.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
status.last_error = None;
|
||||
let should_switch =
|
||||
self.current_provider_id_at_start.as_str()
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||
true
|
||||
}
|
||||
ProxyError::UpstreamError { status, .. } => *status >= 500,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(retry_err.to_string()),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(retry_err.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: retry_err,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 失败:记录失败并更新熔断器
|
||||
let _ = self
|
||||
.router
|
||||
@@ -474,14 +305,6 @@ impl RequestForwarder {
|
||||
Some(format!("Provider {} 失败: {}", provider.name, e));
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
attempted_providers,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
last_error = Some(e);
|
||||
last_provider = Some(provider.clone());
|
||||
// 继续尝试下一个供应商
|
||||
@@ -537,8 +360,6 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
|
||||
|
||||
Err(ForwardError {
|
||||
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||
provider: last_provider,
|
||||
@@ -585,17 +406,16 @@ impl RequestForwarder {
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||
|
||||
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
|
||||
let client = super::http_client::get();
|
||||
// 构建请求
|
||||
let client = self.client.as_ref().ok_or_else(|| {
|
||||
ProxyError::ForwardFailed(
|
||||
self.client_init_error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "HTTP client is not initialized".to_string()),
|
||||
)
|
||||
})?;
|
||||
let mut request = client.post(&url);
|
||||
|
||||
// 只有当 timeout > 0 时才设置请求超时
|
||||
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
|
||||
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
}
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
for (key, value) in headers {
|
||||
if HEADER_BLACKLIST
|
||||
@@ -711,11 +531,3 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 ProxyError 中提取错误消息
|
||||
fn extract_error_message(error: &ProxyError) -> Option<String> {
|
||||
match error {
|
||||
ProxyError::UpstreamError { body, .. } => body.clone(),
|
||||
_ => Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::{
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, RectifierConfig},
|
||||
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -57,8 +54,6 @@ pub struct RequestContext {
|
||||
pub app_type: AppType,
|
||||
/// Session ID(从客户端请求提取或新生成)
|
||||
pub session_id: String,
|
||||
/// 整流器配置
|
||||
pub rectifier_config: RectifierConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -91,9 +86,6 @@ impl RequestContext {
|
||||
.await
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
|
||||
@@ -135,7 +127,7 @@ impl RequestContext {
|
||||
.cloned()
|
||||
.ok_or(ProxyError::NoAvailableProvider)?;
|
||||
|
||||
log::debug!(
|
||||
log::info!(
|
||||
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
|
||||
tag,
|
||||
provider.name,
|
||||
@@ -155,7 +147,6 @@ impl RequestContext {
|
||||
app_type_str,
|
||||
app_type,
|
||||
session_id,
|
||||
rectifier_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -177,6 +168,7 @@ impl RequestContext {
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -198,7 +190,7 @@ impl RequestContext {
|
||||
)
|
||||
} else {
|
||||
// 故障转移关闭:不启用超时配置
|
||||
log::debug!(
|
||||
log::info!(
|
||||
"[{}] Failover disabled, timeout configs are bypassed",
|
||||
self.tag
|
||||
);
|
||||
@@ -215,7 +207,6 @@ impl RequestContext {
|
||||
self.current_provider_id.clone(),
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,16 @@ pub async fn handle_messages(
|
||||
let adapter = get_adapter(&AppType::Claude);
|
||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||
|
||||
log::info!(
|
||||
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
||||
ctx.provider.name,
|
||||
needs_transform,
|
||||
is_stream
|
||||
);
|
||||
|
||||
let status = response.status();
|
||||
log::info!("[Claude] 上游响应状态: {status}");
|
||||
|
||||
// Claude 特有:格式转换处理
|
||||
if needs_transform {
|
||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
||||
@@ -121,6 +131,8 @@ async fn handle_claude_transform(
|
||||
|
||||
if is_stream {
|
||||
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
||||
log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)");
|
||||
|
||||
let stream = response.bytes_stream();
|
||||
let sse_stream = create_anthropic_sse_stream(stream);
|
||||
|
||||
@@ -184,10 +196,13 @@ async fn handle_claude_transform(
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
log::info!("[Claude] ====== 请求结束 (流式转换) ======");
|
||||
return Ok((headers, body).into_response());
|
||||
}
|
||||
|
||||
// 非流式响应转换 (OpenAI → Anthropic)
|
||||
log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)");
|
||||
|
||||
let response_headers = response.headers().clone();
|
||||
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
@@ -196,17 +211,31 @@ async fn handle_claude_transform(
|
||||
})?;
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len());
|
||||
log::debug!("[Claude] OpenAI 原始响应: {body_str}");
|
||||
|
||||
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
||||
})?;
|
||||
|
||||
log::info!("[Claude] 解析 OpenAI 响应成功");
|
||||
log::info!(
|
||||
"[Claude] <<< OpenAI 响应 JSON:\n{}",
|
||||
serde_json::to_string_pretty(&openai_response).unwrap_or_default()
|
||||
);
|
||||
|
||||
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
|
||||
log::error!("[Claude] 转换响应失败: {e}");
|
||||
e
|
||||
})?;
|
||||
|
||||
log::info!("[Claude] 转换响应成功");
|
||||
log::info!(
|
||||
"[Claude] <<< Anthropic 响应 JSON:\n{}",
|
||||
serde_json::to_string_pretty(&anthropic_response).unwrap_or_default()
|
||||
);
|
||||
|
||||
// 记录使用量
|
||||
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
|
||||
let model = anthropic_response
|
||||
@@ -236,6 +265,8 @@ async fn handle_claude_transform(
|
||||
});
|
||||
}
|
||||
|
||||
log::info!("[Claude] ====== 请求结束 ======");
|
||||
|
||||
// 构建响应
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
@@ -254,6 +285,11 @@ async fn handle_claude_transform(
|
||||
ProxyError::TransformError(format!("Failed to serialize response: {e}"))
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"[Claude] 返回转换后的响应, 长度: {} bytes",
|
||||
response_body.len()
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from(response_body);
|
||||
builder.body(body).map_err(|e| {
|
||||
log::error!("[Claude] 构建响应失败: {e}");
|
||||
@@ -271,6 +307,8 @@ pub async fn handle_chat_completions(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
@@ -279,6 +317,12 @@ pub async fn handle_chat_completions(
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
log::info!(
|
||||
"[Codex] 请求模型: {}, 流式: {}",
|
||||
ctx.request_model,
|
||||
is_stream
|
||||
);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
@@ -303,6 +347,8 @@ pub async fn handle_chat_completions(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
@@ -344,6 +390,8 @@ pub async fn handle_responses(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
@@ -369,6 +417,8 @@ pub async fn handle_gemini(
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
|
||||
log::info!("[Gemini] 请求端点: {endpoint}");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
@@ -398,6 +448,8 @@ pub async fn handle_gemini(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
@@ -456,12 +508,7 @@ async fn log_usage(
|
||||
Ok(Some(p)) => {
|
||||
if let Some(meta) = p.meta {
|
||||
if let Some(cm) = meta.cost_multiplier {
|
||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
||||
);
|
||||
Decimal::from(1)
|
||||
})
|
||||
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
@@ -488,6 +535,6 @@ async fn log_usage(
|
||||
None, // provider_type
|
||||
is_streaming,
|
||||
) {
|
||||
log::warn!("[USG-001] 记录使用量失败: {e}");
|
||||
log::warn!("记录使用量失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
//! 全局 HTTP 客户端模块
|
||||
//!
|
||||
//! 提供支持全局代理配置的 HTTP 客户端。
|
||||
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::Client;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 全局 HTTP 客户端实例
|
||||
static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
|
||||
|
||||
/// 当前代理 URL(用于日志和状态查询)
|
||||
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
|
||||
|
||||
/// 初始化全局 HTTP 客户端
|
||||
///
|
||||
/// 应在应用启动时调用一次。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`
|
||||
/// 传入 None 或空字符串表示直连
|
||||
pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let client = build_client(effective_url)?;
|
||||
|
||||
// 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新
|
||||
if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {
|
||||
log::warn!(
|
||||
"[GlobalProxy] [GP-003] Already initialized, updating instead: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
// 已初始化,改用 apply_proxy 更新
|
||||
return apply_proxy(proxy_url);
|
||||
}
|
||||
|
||||
// 初始化代理 URL 记录
|
||||
let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string())));
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Initialized: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证代理配置(不应用)
|
||||
///
|
||||
/// 只验证代理 URL 是否有效,不实际更新全局客户端。
|
||||
/// 用于在持久化之前验证配置的有效性。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 代理 URL,None 或空字符串表示直连
|
||||
///
|
||||
/// # Returns
|
||||
/// 验证成功返回 Ok(()),失败返回错误信息
|
||||
pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
// 只调用 build_client 来验证,但不应用
|
||||
build_client(effective_url)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用代理配置(假设已验证)
|
||||
///
|
||||
/// 直接应用代理配置到全局客户端,不做额外验证。
|
||||
/// 应在 validate_proxy 成功后调用。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 代理 URL,None 或空字符串表示直连
|
||||
pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let new_client = build_client(effective_url)?;
|
||||
|
||||
// 更新客户端
|
||||
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||
let mut client = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||
"Failed to update proxy: lock poisoned".to_string()
|
||||
})?;
|
||||
*client = new_client;
|
||||
} else {
|
||||
// 如果还没初始化,则初始化
|
||||
return init(proxy_url);
|
||||
}
|
||||
|
||||
// 更新代理 URL 记录
|
||||
if let Some(lock) = CURRENT_PROXY_URL.get() {
|
||||
let mut url = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
|
||||
"Failed to update proxy URL record: lock poisoned".to_string()
|
||||
})?;
|
||||
*url = effective_url.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Applied: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新代理配置(热更新)
|
||||
///
|
||||
/// 可在运行时调用以更改代理设置,无需重启应用。
|
||||
/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用,
|
||||
/// 请使用 validate_proxy + apply_proxy 组合。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 新的代理 URL,None 或空字符串表示直连
|
||||
#[allow(dead_code)]
|
||||
pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let new_client = build_client(effective_url)?;
|
||||
|
||||
// 更新客户端
|
||||
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||
let mut client = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||
"Failed to update proxy: lock poisoned".to_string()
|
||||
})?;
|
||||
*client = new_client;
|
||||
} else {
|
||||
// 如果还没初始化,则初始化
|
||||
return init(proxy_url);
|
||||
}
|
||||
|
||||
// 更新代理 URL 记录
|
||||
if let Some(lock) = CURRENT_PROXY_URL.get() {
|
||||
let mut url = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
|
||||
"Failed to update proxy URL record: lock poisoned".to_string()
|
||||
})?;
|
||||
*url = effective_url.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Updated: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取全局 HTTP 客户端
|
||||
///
|
||||
/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。
|
||||
pub fn get() -> Client {
|
||||
GLOBAL_CLIENT
|
||||
.get()
|
||||
.and_then(|lock| lock.read().ok())
|
||||
.map(|c| c.clone())
|
||||
.unwrap_or_else(|| {
|
||||
// 如果还没初始化,创建一个默认客户端(配置与 build_client 一致)
|
||||
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取当前代理 URL
|
||||
///
|
||||
/// 返回当前配置的代理 URL,None 表示直连。
|
||||
pub fn get_current_proxy_url() -> Option<String> {
|
||||
CURRENT_PROXY_URL
|
||||
.get()
|
||||
.and_then(|lock| lock.read().ok())
|
||||
.and_then(|url| url.clone())
|
||||
}
|
||||
|
||||
/// 检查是否正在使用代理
|
||||
#[allow(dead_code)]
|
||||
pub fn is_proxy_enabled() -> bool {
|
||||
get_current_proxy_url().is_some()
|
||||
}
|
||||
|
||||
/// 构建 HTTP 客户端
|
||||
fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
let mut builder = Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60));
|
||||
|
||||
// 有代理地址则使用代理,否则直连
|
||||
if let Some(url) = proxy_url {
|
||||
// 先验证 URL 格式和 scheme
|
||||
let parsed = url::Url::parse(url)
|
||||
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||
|
||||
let scheme = parsed.scheme();
|
||||
if !["http", "https", "socks5", "socks5h"].contains(&scheme) {
|
||||
return Err(format!(
|
||||
"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h",
|
||||
scheme,
|
||||
mask_url(url)
|
||||
));
|
||||
}
|
||||
|
||||
let proxy = reqwest::Proxy::all(url)
|
||||
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||
builder = builder.proxy(proxy);
|
||||
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
|
||||
} else {
|
||||
builder = builder.no_proxy();
|
||||
log::debug!("[GlobalProxy] Direct connection (no proxy)");
|
||||
}
|
||||
|
||||
builder
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))
|
||||
}
|
||||
|
||||
/// 隐藏 URL 中的敏感信息(用于日志)
|
||||
pub fn mask_url(url: &str) -> String {
|
||||
if let Ok(parsed) = url::Url::parse(url) {
|
||||
// 隐藏用户名和密码,保留 scheme、host 和端口
|
||||
let host = parsed.host_str().unwrap_or("?");
|
||||
match parsed.port() {
|
||||
Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port),
|
||||
None => format!("{}://{}", parsed.scheme(), host),
|
||||
}
|
||||
} else {
|
||||
// URL 解析失败,返回部分内容
|
||||
if url.len() > 20 {
|
||||
format!("{}...", &url[..20])
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mask_url() {
|
||||
assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
|
||||
assert_eq!(
|
||||
mask_url("http://user:pass@127.0.0.1:7890"),
|
||||
"http://127.0.0.1:7890"
|
||||
);
|
||||
assert_eq!(
|
||||
mask_url("socks5://admin:secret@proxy.example.com:1080"),
|
||||
"socks5://proxy.example.com:1080"
|
||||
);
|
||||
// 无端口的 URL 不应显示 ":?"
|
||||
assert_eq!(
|
||||
mask_url("http://proxy.example.com"),
|
||||
"http://proxy.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
mask_url("https://user:pass@proxy.example.com"),
|
||||
"https://proxy.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_direct() {
|
||||
let result = build_client(None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_with_http_proxy() {
|
||||
let result = build_client(Some("http://127.0.0.1:7890"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_with_socks5_proxy() {
|
||||
let result = build_client(Some("socks5://127.0.0.1:1080"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_invalid_url() {
|
||||
// reqwest::Proxy::all 对某些无效 URL 不会立即报错
|
||||
// 使用明确无效的 scheme 来触发错误
|
||||
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
|
||||
assert!(result.is_err(), "Should reject invalid proxy scheme");
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
//! 代理模块日志错误码定义
|
||||
//!
|
||||
//! 格式: [模块-编号] 消息
|
||||
//! - CB: Circuit Breaker (熔断器)
|
||||
//! - SRV: Server (服务器)
|
||||
//! - FWD: Forwarder (转发器)
|
||||
//! - FO: Failover (故障转移)
|
||||
//! - RSP: Response (响应处理)
|
||||
//! - USG: Usage (使用量)
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
/// 熔断器日志码
|
||||
pub mod cb {
|
||||
pub const OPEN_TO_HALF_OPEN: &str = "CB-001";
|
||||
pub const HALF_OPEN_TO_CLOSED: &str = "CB-002";
|
||||
pub const HALF_OPEN_PROBE_FAILED: &str = "CB-003";
|
||||
pub const TRIGGERED_FAILURES: &str = "CB-004";
|
||||
pub const TRIGGERED_ERROR_RATE: &str = "CB-005";
|
||||
pub const MANUAL_RESET: &str = "CB-006";
|
||||
}
|
||||
|
||||
/// 服务器日志码
|
||||
pub mod srv {
|
||||
pub const STARTED: &str = "SRV-001";
|
||||
pub const STOPPED: &str = "SRV-002";
|
||||
pub const STOP_TIMEOUT: &str = "SRV-003";
|
||||
pub const TASK_ERROR: &str = "SRV-004";
|
||||
}
|
||||
|
||||
/// 转发器日志码
|
||||
pub mod fwd {
|
||||
pub const PROVIDER_FAILED_RETRY: &str = "FWD-001";
|
||||
pub const ALL_PROVIDERS_FAILED: &str = "FWD-002";
|
||||
}
|
||||
|
||||
/// 故障转移日志码
|
||||
pub mod fo {
|
||||
pub const SWITCH_SUCCESS: &str = "FO-001";
|
||||
pub const CONFIG_READ_ERROR: &str = "FO-002";
|
||||
pub const LIVE_BACKUP_ERROR: &str = "FO-003";
|
||||
pub const ALL_CIRCUIT_OPEN: &str = "FO-004";
|
||||
pub const NO_PROVIDERS: &str = "FO-005";
|
||||
}
|
||||
|
||||
/// 响应处理日志码
|
||||
pub mod rsp {
|
||||
pub const BUILD_STREAM_ERROR: &str = "RSP-001";
|
||||
pub const READ_BODY_ERROR: &str = "RSP-002";
|
||||
pub const BUILD_RESPONSE_ERROR: &str = "RSP-003";
|
||||
pub const STREAM_TIMEOUT: &str = "RSP-004";
|
||||
pub const STREAM_ERROR: &str = "RSP-005";
|
||||
}
|
||||
|
||||
/// 使用量日志码
|
||||
pub mod usg {
|
||||
pub const LOG_FAILED: &str = "USG-001";
|
||||
pub const PRICING_NOT_FOUND: &str = "USG-002";
|
||||
}
|
||||
@@ -12,8 +12,6 @@ pub mod handler_config;
|
||||
pub mod handler_context;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod http_client;
|
||||
pub mod log_codes;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
pub mod providers;
|
||||
@@ -21,7 +19,6 @@ pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ pub fn apply_model_mapping(
|
||||
let mapped = mapping.map_model(original, has_thinking);
|
||||
|
||||
if mapped != *original {
|
||||
log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
|
||||
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
|
||||
body["model"] = serde_json::json!(mapped);
|
||||
return (body, Some(original.clone()), Some(mapped));
|
||||
}
|
||||
|
||||
@@ -39,9 +39,15 @@ impl ProviderRouter {
|
||||
|
||||
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
|
||||
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(config) => config.auto_failover_enabled,
|
||||
Ok(config) => {
|
||||
let enabled = config.auto_failover_enabled;
|
||||
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
|
||||
enabled
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
|
||||
log::error!(
|
||||
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
@@ -50,37 +56,85 @@ impl ProviderRouter {
|
||||
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||
total_providers = failover_providers.len();
|
||||
log::debug!("[{app_type}] Found {total_providers} failover queue provider(s)");
|
||||
log::info!(
|
||||
"[{app_type}] Failover enabled, using queue order ({total_providers} items)"
|
||||
);
|
||||
|
||||
for provider in failover_providers {
|
||||
// 检查熔断器状态
|
||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
let state = breaker.get_state().await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
log::debug!(
|
||||
"[{}] Queue provider available: {} ({}) (state: {:?})",
|
||||
app_type,
|
||||
provider.name,
|
||||
provider.id,
|
||||
state
|
||||
);
|
||||
log::info!(
|
||||
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
||||
app_type,
|
||||
provider.name,
|
||||
provider.id,
|
||||
provider.sort_index
|
||||
);
|
||||
result.push(provider);
|
||||
} else {
|
||||
circuit_open_count += 1;
|
||||
log::debug!(
|
||||
"[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
|
||||
app_type,
|
||||
provider.name,
|
||||
state
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
|
||||
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
|
||||
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
|
||||
|
||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||
log::info!(
|
||||
"[{}] Current provider: {} ({})",
|
||||
app_type,
|
||||
current.name,
|
||||
current.id
|
||||
);
|
||||
total_providers = 1;
|
||||
result.push(current);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[{app_type}] Current provider id {current_id} not found in database"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::debug!("[{app_type}] No current provider configured");
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
// 区分两种情况:全部熔断 vs 未配置供应商
|
||||
if total_providers > 0 && circuit_open_count == total_providers {
|
||||
log::warn!("[{app_type}] [FO-004] 所有供应商均已熔断");
|
||||
log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道");
|
||||
return Err(AppError::AllProvidersCircuitOpen);
|
||||
} else {
|
||||
log::warn!("[{app_type}] [FO-005] 未配置供应商");
|
||||
log::warn!("[{app_type}] 未配置供应商或故障转移队列为空");
|
||||
return Err(AppError::NoProvidersConfigured);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] Provider chain: {} provider(s) available",
|
||||
app_type,
|
||||
result.len()
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -107,10 +161,15 @@ impl ProviderRouter {
|
||||
success: bool,
|
||||
error_msg: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
// 1. 按应用独立获取熔断器配置
|
||||
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
|
||||
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(app_config) => app_config.circuit_failure_threshold,
|
||||
Err(_) => 5, // 默认值
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to load circuit config for {app_type}, using default threshold: {e}"
|
||||
);
|
||||
5 // 默认值
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 更新熔断器状态
|
||||
@@ -119,8 +178,14 @@ impl ProviderRouter {
|
||||
|
||||
if success {
|
||||
breaker.record_success(used_half_open_permit).await;
|
||||
log::debug!("Provider {provider_id} request succeeded");
|
||||
} else {
|
||||
breaker.record_failure(used_half_open_permit).await;
|
||||
log::warn!(
|
||||
"Provider {} request failed: {}",
|
||||
provider_id,
|
||||
error_msg.as_deref().unwrap_or("Unknown error")
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 更新数据库健康状态(使用配置的阈值)
|
||||
@@ -141,6 +206,7 @@ impl ProviderRouter {
|
||||
pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
|
||||
let breakers = self.circuit_breakers.read().await;
|
||||
if let Some(breaker) = breakers.get(circuit_key) {
|
||||
log::info!("Manually resetting circuit breaker for {circuit_key}");
|
||||
breaker.reset().await;
|
||||
}
|
||||
}
|
||||
@@ -151,30 +217,19 @@ impl ProviderRouter {
|
||||
self.reset_circuit_breaker(&circuit_key).await;
|
||||
}
|
||||
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口)
|
||||
///
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub async fn release_permit_neutral(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
used_half_open_permit: bool,
|
||||
) {
|
||||
if !used_half_open_permit {
|
||||
return;
|
||||
}
|
||||
let circuit_key = format!("{app_type}:{provider_id}");
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
breaker.release_half_open_permit();
|
||||
}
|
||||
|
||||
/// 更新所有熔断器的配置(热更新)
|
||||
///
|
||||
/// 当用户在 UI 中修改熔断器配置后调用此方法,
|
||||
/// 所有现有的熔断器会立即使用新配置
|
||||
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
||||
let breakers = self.circuit_breakers.read().await;
|
||||
let count = breakers.len();
|
||||
|
||||
for breaker in breakers.values() {
|
||||
breaker.update_config(config.clone()).await;
|
||||
}
|
||||
|
||||
log::info!("已更新 {count} 个熔断器的配置");
|
||||
}
|
||||
|
||||
/// 获取熔断器状态
|
||||
@@ -217,16 +272,32 @@ impl ProviderRouter {
|
||||
|
||||
// 按应用独立读取熔断器配置
|
||||
let config = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||
failure_threshold: app_config.circuit_failure_threshold,
|
||||
success_threshold: app_config.circuit_success_threshold,
|
||||
timeout_seconds: app_config.circuit_timeout_seconds as u64,
|
||||
error_rate_threshold: app_config.circuit_error_rate_threshold,
|
||||
min_requests: app_config.circuit_min_requests,
|
||||
},
|
||||
Err(_) => crate::proxy::circuit_breaker::CircuitBreakerConfig::default(),
|
||||
Ok(app_config) => {
|
||||
log::debug!(
|
||||
"Loading circuit breaker config for {key} (app={app_type}): \
|
||||
failure_threshold={}, success_threshold={}, timeout={}s",
|
||||
app_config.circuit_failure_threshold,
|
||||
app_config.circuit_success_threshold,
|
||||
app_config.circuit_timeout_seconds
|
||||
);
|
||||
crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||
failure_threshold: app_config.circuit_failure_threshold,
|
||||
success_threshold: app_config.circuit_success_threshold,
|
||||
timeout_seconds: app_config.circuit_timeout_seconds as u64,
|
||||
error_rate_threshold: app_config.circuit_error_rate_threshold,
|
||||
min_requests: app_config.circuit_min_requests,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
|
||||
);
|
||||
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
|
||||
|
||||
let breaker = Arc::new(CircuitBreaker::new(config));
|
||||
breakers.insert(key.to_string(), breaker.clone());
|
||||
|
||||
@@ -343,55 +414,4 @@ mod tests {
|
||||
|
||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_release_permit_neutral_frees_half_open_slot() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
|
||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
|
||||
// 启用自动故障转移
|
||||
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||
config.auto_failover_enabled = true;
|
||||
db.update_proxy_config_for_app(config).await.unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
|
||||
// 触发熔断:1 次失败
|
||||
router
|
||||
.record_result("a", "claude", false, false, Some("fail".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 第一次请求:获取 HalfOpen 探测名额
|
||||
let first = router.allow_provider_request("a", "claude").await;
|
||||
assert!(first.allowed);
|
||||
assert!(first.used_half_open_permit);
|
||||
|
||||
// 第二次请求应被拒绝(名额已被占用)
|
||||
let second = router.allow_provider_request("a", "claude").await;
|
||||
assert!(!second.allowed);
|
||||
|
||||
// 使用 release_permit_neutral 释放名额(不影响健康统计)
|
||||
router
|
||||
.release_permit_neutral("a", "claude", first.used_half_open_permit)
|
||||
.await;
|
||||
|
||||
// 第三次请求应被允许(名额已释放)
|
||||
let third = router.allow_provider_request("a", "claude").await;
|
||||
assert!(third.allowed);
|
||||
assert!(third.used_half_open_permit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,7 @@ impl ClaudeAdapter {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
// OpenRouter now supports Claude Code compatible API, default to passthrough
|
||||
_ => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,22 +465,12 @@ mod tests {
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||
|
||||
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
|
||||
let openrouter_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
}
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&openrouter_provider));
|
||||
|
||||
// OpenRouter provider with explicit compat mode enabled should transform
|
||||
let openrouter_enabled = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
},
|
||||
"openrouter_compat_mode": true
|
||||
}));
|
||||
assert!(adapter.needs_transform(&openrouter_enabled));
|
||||
assert!(adapter.needs_transform(&openrouter_provider));
|
||||
|
||||
let openrouter_disabled = create_provider(json!({
|
||||
"env": {
|
||||
|
||||
@@ -75,6 +75,8 @@ pub fn create_anthropic_sse_stream(
|
||||
let mut current_block_type: Option<String> = None;
|
||||
let mut tool_call_id = None;
|
||||
|
||||
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
@@ -94,18 +96,25 @@ pub fn create_anthropic_sse_stream(
|
||||
for l in line.lines() {
|
||||
if let Some(data) = l.strip_prefix("data: ") {
|
||||
if data.trim() == "[DONE]" {
|
||||
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
||||
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
||||
let event = json!({"type": "message_stop"});
|
||||
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
|
||||
log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
||||
// 仅在 DEBUG 级别简短记录 SSE 事件
|
||||
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
|
||||
// 记录原始 OpenAI 事件(格式化显示)
|
||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
log::info!(
|
||||
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
|
||||
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
||||
);
|
||||
} else {
|
||||
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
|
||||
}
|
||||
|
||||
if message_id.is_none() {
|
||||
message_id = Some(chunk.id.clone());
|
||||
|
||||
@@ -46,6 +46,8 @@ pub async fn handle_streaming(
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
) -> Response {
|
||||
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
|
||||
|
||||
let status = response.status();
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
@@ -97,6 +99,12 @@ pub async fn handle_non_streaming(
|
||||
|
||||
// 解析并记录使用量
|
||||
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
||||
log::info!(
|
||||
"[{}] <<< 响应 JSON:\n{}",
|
||||
ctx.tag,
|
||||
serde_json::to_string_pretty(&json_value).unwrap_or_default()
|
||||
);
|
||||
|
||||
// 解析使用量
|
||||
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
||||
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
||||
@@ -129,7 +137,7 @@ pub async fn handle_non_streaming(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
log::info!(
|
||||
"[{}] <<< 响应 (非 JSON): {} bytes",
|
||||
ctx.tag,
|
||||
body_bytes.len()
|
||||
@@ -144,6 +152,8 @@ pub async fn handle_non_streaming(
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("[{}] ====== 请求结束 ======", ctx.tag);
|
||||
|
||||
// 构建响应
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
for (key, value) in response_headers.iter() {
|
||||
@@ -372,12 +382,7 @@ async fn log_usage_internal(
|
||||
Ok(Some(p)) => {
|
||||
if let Some(meta) = p.meta {
|
||||
if let Some(cm) = meta.cost_multiplier {
|
||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
||||
);
|
||||
Decimal::from(1)
|
||||
})
|
||||
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
@@ -413,7 +418,7 @@ async fn log_usage_internal(
|
||||
None, // provider_type
|
||||
is_streaming,
|
||||
) {
|
||||
log::warn!("[USG-001] 记录使用量失败: {e}");
|
||||
log::warn!("记录使用量失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,16 +493,16 @@ pub fn create_logged_passthrough_stream(
|
||||
if let Some(c) = &collector {
|
||||
c.push(json_value.clone()).await;
|
||||
}
|
||||
log::debug!(
|
||||
"[{}] <<< SSE 事件: {}",
|
||||
log::info!(
|
||||
"[{}] <<< SSE 事件:\n{}",
|
||||
tag,
|
||||
data.chars().take(100).collect::<String>()
|
||||
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
||||
);
|
||||
} else {
|
||||
log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::<String>());
|
||||
log::info!("[{tag}] <<< SSE 数据: {data}");
|
||||
}
|
||||
} else {
|
||||
log::debug!("[{tag}] <<< SSE: [DONE]");
|
||||
log::info!("[{tag}] <<< SSE: [DONE]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,6 +523,8 @@ pub fn create_logged_passthrough_stream(
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[{}] ====== 流结束 ======", tag);
|
||||
|
||||
if let Some(c) = collector.take() {
|
||||
c.finish().await;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! 基于Axum的HTTP服务器,处理代理请求
|
||||
|
||||
use super::{
|
||||
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
|
||||
provider_router::ProviderRouter, types::*, ProxyError,
|
||||
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::database::Database;
|
||||
use axum::{
|
||||
@@ -95,7 +95,7 @@ impl ProxyServer {
|
||||
.await
|
||||
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
|
||||
|
||||
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
|
||||
log::info!("代理服务器启动于 {addr}");
|
||||
|
||||
// 保存关闭句柄
|
||||
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||
@@ -146,25 +146,13 @@ impl ProxyServer {
|
||||
// 2. 等待服务器任务结束(带 5 秒超时保护)
|
||||
if let Some(handle) = self.server_handle.write().await.take() {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
|
||||
Ok(Ok(())) => {
|
||||
log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("[{}] 代理服务器任务异常终止: {e}", log_srv::TASK_ERROR);
|
||||
Err(ProxyError::StopFailed(e.to_string()))
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"[{}] 代理服务器停止超时(5秒),强制继续",
|
||||
log_srv::STOP_TIMEOUT
|
||||
);
|
||||
Err(ProxyError::StopTimeout)
|
||||
}
|
||||
Ok(Ok(())) => log::info!("代理服务器已完全停止"),
|
||||
Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"),
|
||||
Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"),
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_status(&self) -> ProxyStatus {
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
//! Thinking Signature 整流器
|
||||
//!
|
||||
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
|
||||
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
|
||||
|
||||
use super::types::RectifierConfig;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 整流结果
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RectifyResult {
|
||||
/// 是否应用了整流
|
||||
pub applied: bool,
|
||||
/// 移除的 thinking block 数量
|
||||
pub removed_thinking_blocks: usize,
|
||||
/// 移除的 redacted_thinking block 数量
|
||||
pub removed_redacted_thinking_blocks: usize,
|
||||
/// 移除的 signature 字段数量
|
||||
pub removed_signature_fields: usize,
|
||||
}
|
||||
|
||||
/// 检测是否需要触发 thinking 签名整流器
|
||||
///
|
||||
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
|
||||
/// 会检查配置开关。
|
||||
pub fn should_rectify_thinking_signature(
|
||||
error_message: Option<&str>,
|
||||
config: &RectifierConfig,
|
||||
) -> bool {
|
||||
// 检查总开关
|
||||
if !config.enabled {
|
||||
return false;
|
||||
}
|
||||
// 检查子开关
|
||||
if !config.request_thinking_signature {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检测错误类型
|
||||
let Some(msg) = error_message else {
|
||||
return false;
|
||||
};
|
||||
let lower = msg.to_lowercase();
|
||||
|
||||
// 场景1: thinking block 中的签名无效
|
||||
// 错误示例: "Invalid 'signature' in 'thinking' block"
|
||||
if lower.contains("invalid")
|
||||
&& lower.contains("signature")
|
||||
&& lower.contains("thinking")
|
||||
&& lower.contains("block")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景2: assistant 消息必须以 thinking block 开头
|
||||
// 错误示例: "must start with a thinking block"
|
||||
if lower.contains("must start with a thinking block") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景3: expected thinking or redacted_thinking, found tool_use
|
||||
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
|
||||
if lower.contains("expected")
|
||||
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||
&& lower.contains("found")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景4: signature 字段必需但缺失
|
||||
// 错误示例: "signature: Field required"
|
||||
if lower.contains("signature") && lower.contains("field required") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 对 Anthropic 请求体做最小侵入整流
|
||||
///
|
||||
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
|
||||
/// - 移除非 thinking block 上遗留的 signature 字段
|
||||
/// - 特定条件下删除顶层 thinking 字段
|
||||
///
|
||||
/// 注意:该函数会原地修改 body 对象
|
||||
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
|
||||
let mut result = RectifyResult::default();
|
||||
|
||||
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
Some(m) => m,
|
||||
None => return result,
|
||||
};
|
||||
|
||||
// 遍历所有消息
|
||||
for msg in messages.iter_mut() {
|
||||
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let mut new_content = Vec::with_capacity(content.len());
|
||||
let mut content_modified = false;
|
||||
|
||||
for block in content.iter() {
|
||||
let block_type = block.get("type").and_then(|t| t.as_str());
|
||||
|
||||
match block_type {
|
||||
Some("thinking") => {
|
||||
result.removed_thinking_blocks += 1;
|
||||
content_modified = true;
|
||||
continue;
|
||||
}
|
||||
Some("redacted_thinking") => {
|
||||
result.removed_redacted_thinking_blocks += 1;
|
||||
content_modified = true;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 移除非 thinking block 上的 signature 字段
|
||||
if block.get("signature").is_some() {
|
||||
let mut block_clone = block.clone();
|
||||
if let Some(obj) = block_clone.as_object_mut() {
|
||||
obj.remove("signature");
|
||||
result.removed_signature_fields += 1;
|
||||
content_modified = true;
|
||||
new_content.push(Value::Object(obj.clone()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
new_content.push(block.clone());
|
||||
}
|
||||
|
||||
if content_modified {
|
||||
result.applied = true;
|
||||
*content = new_content;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
|
||||
let messages_snapshot: Vec<Value> = body
|
||||
.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|a| a.to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
if should_remove_top_level_thinking(body, &messages_snapshot) {
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.remove("thinking");
|
||||
result.applied = true;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 判断是否需要删除顶层 thinking 字段
|
||||
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||
// 检查 thinking 是否启用
|
||||
let thinking_enabled = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled");
|
||||
|
||||
if !thinking_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 找到最后一条 assistant 消息
|
||||
let last_assistant = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
|
||||
|
||||
let last_assistant_content = match last_assistant
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
{
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// 检查首块是否为 thinking/redacted_thinking
|
||||
let first_block_type = last_assistant_content
|
||||
.first()
|
||||
.and_then(|b| b.get("type"))
|
||||
.and_then(|t| t.as_str());
|
||||
|
||||
let missing_thinking_prefix =
|
||||
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
|
||||
|
||||
if !missing_thinking_prefix {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否存在 tool_use
|
||||
last_assistant_content
|
||||
.iter()
|
||||
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn enabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn master_disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== should_rectify_thinking_signature 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature_no_backticks() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("Messages.1.Content.0: invalid signature in thinking block"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature_nested_json() {
|
||||
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
|
||||
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(nested_error),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_thinking_expected() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_must_start_with_thinking() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("a final `assistant` message must start with a thinking block"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_trigger_for_unrelated_error() {
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Request timeout"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Connection refused"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_signature_field_required() {
|
||||
// 场景4: signature 字段缺失
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("***.***.***.***.***.signature: Field required"),
|
||||
&enabled_config()
|
||||
));
|
||||
// 嵌套 JSON 格式
|
||||
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(nested_error),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_config() {
|
||||
// 即使错误匹配,配置关闭时也不触发
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Invalid `signature` in `thinking` block"),
|
||||
&disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_disabled() {
|
||||
// 总开关关闭时,即使子开关开启也不触发
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Invalid `signature` in `thinking` block"),
|
||||
&master_disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== rectify_anthropic_request 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_rectify_removes_thinking_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "t", "signature": "sig" },
|
||||
{ "type": "text", "text": "hello", "signature": "sig_text" },
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
|
||||
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 1);
|
||||
assert_eq!(result.removed_redacted_thinking_blocks, 1);
|
||||
assert_eq!(result.removed_signature_fields, 2);
|
||||
|
||||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 2);
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert!(content[0].get("signature").is_none());
|
||||
assert_eq!(content[1]["type"], "tool_use");
|
||||
assert!(content[1].get("signature").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_removes_top_level_thinking() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 1024 },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
|
||||
]
|
||||
}, {
|
||||
"role": "user",
|
||||
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert!(body.get("thinking").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_no_change_when_no_issues() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_no_messages() {
|
||||
let mut body = json!({ "model": "claude-test" });
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
assert!(!result.applied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_preserves_thinking_when_prefix_exists() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled" },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "some thought" },
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 1);
|
||||
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
|
||||
// 此时会触发删除顶层 thinking 的逻辑
|
||||
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
|
||||
}
|
||||
}
|
||||
@@ -191,67 +191,3 @@ pub struct AppProxyConfig {
|
||||
/// 计算错误率的最小请求数
|
||||
pub circuit_min_requests: u32,
|
||||
}
|
||||
|
||||
/// 整流器配置
|
||||
///
|
||||
/// 存储在 settings 表中
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RectifierConfig {
|
||||
/// 总开关:是否启用整流器
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 请求整流:启用 thinking 签名整流器
|
||||
///
|
||||
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_signature: bool,
|
||||
}
|
||||
|
||||
impl Default for RectifierConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_default_enabled() {
|
||||
// 验证 RectifierConfig::default() 返回全启用状态
|
||||
// 防止回归:#[derive(Default)] 会使 bool 默认为 false
|
||||
let config = RectifierConfig::default();
|
||||
assert!(config.enabled, "整流器总开关默认应为 true");
|
||||
assert!(
|
||||
config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_default() {
|
||||
// 验证反序列化缺字段时使用 default_true
|
||||
let json = "{}";
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_explicit_false() {
|
||||
// 验证显式设置 false 时正确反序列化
|
||||
let json = r#"{"enabled": false, "requestThinkingSignature": false}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ impl<'a> UsageLogger<'a> {
|
||||
let pricing = self.get_model_pricing(&model)?;
|
||||
|
||||
if pricing.is_none() {
|
||||
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
|
||||
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0");
|
||||
}
|
||||
|
||||
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod proxy;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod stream_check;
|
||||
pub mod template;
|
||||
pub mod usage_stats;
|
||||
|
||||
pub use config::ConfigService;
|
||||
@@ -20,12 +19,6 @@ pub use proxy::ProxyService;
|
||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
#[allow(unused_imports)]
|
||||
pub use template::{
|
||||
BatchInstallResult, ComponentDetail, ComponentMetadata, ComponentType, InstalledComponent,
|
||||
MarketplaceBundle, MarketplaceBundleItem, PaginatedResult, TemplateComponent, TemplateRepo,
|
||||
TemplateService,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use usage_stats::{
|
||||
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
||||
RequestLogDetail, UsageSummary,
|
||||
|
||||
@@ -85,7 +85,7 @@ impl PromptService {
|
||||
if !content_exists {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let backup_id = format!("backup-{timestamp}");
|
||||
let backup_prompt = Prompt {
|
||||
|
||||
@@ -148,15 +148,6 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
|
||||
// MCP sync
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
// Skill sync
|
||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
|
||||
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
|
||||
// Continue syncing other apps, don't abort
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
@@ -142,7 +143,9 @@ pub struct SkillMetadata {
|
||||
|
||||
// ========== SkillService ==========
|
||||
|
||||
pub struct SkillService;
|
||||
pub struct SkillService {
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
impl Default for SkillService {
|
||||
fn default() -> Self {
|
||||
@@ -152,7 +155,13 @@ impl Default for SkillService {
|
||||
|
||||
impl SkillService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
http_client: Client::builder()
|
||||
.user_agent("cc-switch")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 路径管理 ==========
|
||||
@@ -854,8 +863,7 @@ impl SkillService {
|
||||
|
||||
/// 下载并解压 ZIP
|
||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
let response = client.get(url).send().await?;
|
||||
let response = self.http_client.get(url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16().to_string();
|
||||
return Err(anyhow::anyhow!(format_skill_error(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use futures::future::join_all;
|
||||
use reqwest::{Client, Url};
|
||||
use serde::Serialize;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@@ -65,21 +65,17 @@ impl SpeedtestService {
|
||||
}
|
||||
|
||||
let timeout = Self::sanitize_timeout(timeout_secs);
|
||||
let (client, request_timeout) = Self::build_client(timeout)?;
|
||||
let client = Self::build_client(timeout)?;
|
||||
|
||||
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
||||
let _ = client
|
||||
.get(parsed_url.clone())
|
||||
.timeout(request_timeout)
|
||||
.send()
|
||||
.await;
|
||||
let _ = client.get(parsed_url.clone()).send().await;
|
||||
|
||||
// 第二次请求开始计时,并将其作为结果返回。
|
||||
let start = Instant::now();
|
||||
let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
|
||||
let latency = match client.get(parsed_url).send().await {
|
||||
Ok(resp) => EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: Some(start.elapsed().as_millis()),
|
||||
@@ -116,11 +112,19 @@ impl SpeedtestService {
|
||||
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
// 返回 timeout Duration 供请求级别使用
|
||||
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||
Ok((crate::proxy::http_client::get(), timeout))
|
||||
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.user_agent("cc-switch-speedtest/1.0")
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"speedtest.client_create_failed",
|
||||
format!("创建 HTTP 客户端失败: {e}"),
|
||||
format!("Failed to create HTTP client: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||
|
||||
@@ -7,7 +7,7 @@ use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
@@ -36,13 +36,6 @@ pub struct StreamCheckConfig {
|
||||
pub codex_model: String,
|
||||
/// Gemini 测试模型
|
||||
pub gemini_model: String,
|
||||
/// 检查提示词
|
||||
#[serde(default = "default_test_prompt")]
|
||||
pub test_prompt: String,
|
||||
}
|
||||
|
||||
fn default_test_prompt() -> String {
|
||||
"Who are you?".to_string()
|
||||
}
|
||||
|
||||
impl Default for StreamCheckConfig {
|
||||
@@ -54,7 +47,6 @@ impl Default for StreamCheckConfig {
|
||||
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||
codex_model: "gpt-5.1-codex@low".to_string(),
|
||||
gemini_model: "gemini-3-pro-preview".to_string(),
|
||||
test_prompt: default_test_prompt(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +110,7 @@ impl StreamCheckService {
|
||||
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: "Check failed".to_string(),
|
||||
message: "检查失败".to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
@@ -138,52 +130,29 @@ impl StreamCheckService {
|
||||
|
||||
let base_url = adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
|
||||
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||
|
||||
let auth = adapter
|
||||
.extract_auth(provider)
|
||||
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
||||
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(config.timeout_secs))
|
||||
.user_agent("cc-switch/1.0")
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
||||
|
||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||
let test_prompt = &config.test_prompt;
|
||||
|
||||
let result = match app_type {
|
||||
AppType::Claude => {
|
||||
Self::check_claude_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
}
|
||||
AppType::Codex => {
|
||||
Self::check_codex_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
}
|
||||
AppType::Gemini => {
|
||||
Self::check_gemini_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,7 +166,7 @@ impl StreamCheckService {
|
||||
Ok(StreamCheckResult {
|
||||
status: health_status,
|
||||
success: true,
|
||||
message: "Check succeeded".to_string(),
|
||||
message: "检查成功".to_string(),
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: Some(status_code),
|
||||
model_used: model,
|
||||
@@ -219,69 +188,31 @@ impl StreamCheckService {
|
||||
}
|
||||
|
||||
/// Claude 流式检查
|
||||
///
|
||||
/// 严格按照 Claude CLI 真实请求格式构建请求
|
||||
async fn check_claude_stream(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/messages?beta=true")
|
||||
format!("{base}/messages")
|
||||
} else {
|
||||
format!("{base}/v1/messages?beta=true")
|
||||
format!("{base}/v1/messages")
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 1,
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"messages": [{ "role": "user", "content": "hi" }],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
// 获取本地系统信息
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// 严格按照 Claude CLI 请求格式设置 headers
|
||||
let response = client
|
||||
.post(&url)
|
||||
// 认证 headers(双重认证)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key)
|
||||
// Anthropic 必需 headers
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header(
|
||||
"anthropic-beta",
|
||||
"claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
)
|
||||
.header("anthropic-dangerous-direct-browser-access", "true")
|
||||
// 内容类型 headers
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json")
|
||||
.header("accept-encoding", "identity")
|
||||
.header("accept-language", "*")
|
||||
// 客户端标识 headers
|
||||
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
|
||||
.header("x-app", "cli")
|
||||
// x-stainless SDK headers(动态获取本地系统信息)
|
||||
.header("x-stainless-lang", "js")
|
||||
.header("x-stainless-package-version", "0.70.0")
|
||||
.header("x-stainless-os", os_name)
|
||||
.header("x-stainless-arch", arch_name)
|
||||
.header("x-stainless-runtime", "node")
|
||||
.header("x-stainless-runtime-version", "v22.20.0")
|
||||
.header("x-stainless-retry-count", "0")
|
||||
.header("x-stainless-timeout", "600")
|
||||
// 其他 headers
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("connection", "keep-alive")
|
||||
.timeout(timeout)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -299,64 +230,51 @@ impl StreamCheckService {
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Message("No response data received".to_string()))
|
||||
Err(AppError::Message("未收到响应数据".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex 流式检查
|
||||
///
|
||||
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
|
||||
async fn check_codex_stream(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/responses")
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
format!("{base}/v1/responses")
|
||||
format!("{base}/v1/chat/completions")
|
||||
};
|
||||
|
||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
||||
|
||||
// 获取本地系统信息
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// Responses API 请求体格式 (input 必须是数组)
|
||||
let mut body = json!({
|
||||
"model": actual_model,
|
||||
"input": [{ "role": "user", "content": test_prompt }],
|
||||
"messages": [
|
||||
{ "role": "system", "content": "" },
|
||||
{ "role": "assistant", "content": "" },
|
||||
{ "role": "user", "content": "hi" }
|
||||
],
|
||||
"max_tokens": 1,
|
||||
"temperature": 0,
|
||||
"stream": true
|
||||
});
|
||||
|
||||
// 如果是推理模型,添加 reasoning_effort
|
||||
if let Some(effort) = reasoning_effort {
|
||||
body["reasoning"] = json!({ "effort": effort });
|
||||
body["reasoning_effort"] = json!(effort);
|
||||
}
|
||||
|
||||
// 严格按照 Codex CLI 请求格式设置 headers
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.header(
|
||||
"user-agent",
|
||||
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
|
||||
)
|
||||
.header("originator", "codex_cli_rs")
|
||||
.timeout(timeout)
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -373,10 +291,10 @@ impl StreamCheckService {
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Message("No response data received".to_string()))
|
||||
Err(AppError::Message("未收到响应数据".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,15 +304,13 @@ impl StreamCheckService {
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = format!("{base}/v1/chat/completions");
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"messages": [{ "role": "user", "content": "hi" }],
|
||||
"max_tokens": 1,
|
||||
"temperature": 0,
|
||||
"stream": true
|
||||
@@ -404,7 +320,6 @@ impl StreamCheckService {
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -421,10 +336,10 @@ impl StreamCheckService {
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Message("No response data received".to_string()))
|
||||
Err(AppError::Message("未收到响应数据".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +354,7 @@ impl StreamCheckService {
|
||||
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
/// 返回 (实际模型名, Option<推理等级>)
|
||||
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
||||
// 查找 @ 或 # 分隔符
|
||||
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
||||
let actual_model = model[..pos].to_string();
|
||||
let effort = model[pos + 1..].to_string();
|
||||
@@ -451,14 +367,17 @@ impl StreamCheckService {
|
||||
|
||||
fn should_retry(msg: &str) -> bool {
|
||||
let lower = msg.to_lowercase();
|
||||
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
|
||||
lower.contains("timeout")
|
||||
|| lower.contains("abort")
|
||||
|| lower.contains("中断")
|
||||
|| lower.contains("超时")
|
||||
}
|
||||
|
||||
fn map_request_error(e: reqwest::Error) -> AppError {
|
||||
if e.is_timeout() {
|
||||
AppError::Message("Request timeout".to_string())
|
||||
AppError::Message("请求超时".to_string())
|
||||
} else if e.is_connect() {
|
||||
AppError::Message(format!("Connection failed: {e}"))
|
||||
AppError::Message(format!("连接失败: {e}"))
|
||||
} else {
|
||||
AppError::Message(e.to_string())
|
||||
}
|
||||
@@ -505,26 +424,6 @@ impl StreamCheckService {
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// 获取操作系统名称(映射为 Claude CLI 使用的格式)
|
||||
fn get_os_name() -> &'static str {
|
||||
match std::env::consts::OS {
|
||||
"macos" => "MacOS",
|
||||
"linux" => "Linux",
|
||||
"windows" => "Windows",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式)
|
||||
fn get_arch_name() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"aarch64" => "arm64",
|
||||
"x86_64" => "x86_64",
|
||||
"x86" => "x86",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -549,10 +448,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_should_retry() {
|
||||
assert!(StreamCheckService::should_retry("Request timeout"));
|
||||
assert!(StreamCheckService::should_retry("request timed out"));
|
||||
assert!(StreamCheckService::should_retry("connection abort"));
|
||||
assert!(!StreamCheckService::should_retry("API Key invalid"));
|
||||
assert!(StreamCheckService::should_retry("请求超时"));
|
||||
assert!(StreamCheckService::should_retry("request timeout"));
|
||||
assert!(!StreamCheckService::should_retry("API Key 无效"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -580,33 +478,4 @@ mod tests {
|
||||
assert_eq!(model, "gpt-4o-mini");
|
||||
assert_eq!(effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_os_name() {
|
||||
let os_name = StreamCheckService::get_os_name();
|
||||
// 确保返回非空字符串
|
||||
assert!(!os_name.is_empty());
|
||||
// 在 macOS 上应该返回 "MacOS"
|
||||
#[cfg(target_os = "macos")]
|
||||
assert_eq!(os_name, "MacOS");
|
||||
// 在 Linux 上应该返回 "Linux"
|
||||
#[cfg(target_os = "linux")]
|
||||
assert_eq!(os_name, "Linux");
|
||||
// 在 Windows 上应该返回 "Windows"
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(os_name, "Windows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_arch_name() {
|
||||
let arch_name = StreamCheckService::get_arch_name();
|
||||
// 确保返回非空字符串
|
||||
assert!(!arch_name.is_empty());
|
||||
// 在 ARM64 上应该返回 "arm64"
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
assert_eq!(arch_name, "arm64");
|
||||
// 在 x86_64 上应该返回 "x86_64"
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
assert_eq!(arch_name, "x86_64");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
//! Claude 应用适配器
|
||||
//!
|
||||
//! 完整支持所有组件类型:
|
||||
//! - Agent → `~/.claude/agents/{name}.md`
|
||||
//! - Command → `~/.claude/commands/{name}.md`
|
||||
//! - MCP → 合并到 `~/.claude.json` 的 mcpServers 字段
|
||||
//! - Setting → 合并到 `~/.claude/settings.json` 的 permissions 字段
|
||||
//! - Hook → 合并到 `~/.claude/settings.json` 的 hooks 字段
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::AppAdapter;
|
||||
use crate::config::{atomic_write, get_claude_config_dir, get_claude_mcp_path};
|
||||
|
||||
/// Claude 应用适配器
|
||||
pub struct ClaudeAdapter {
|
||||
config_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ClaudeAdapter {
|
||||
/// 创建新的 Claude 适配器实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config_dir: get_claude_config_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 JSON 配置文件
|
||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
||||
if !path.exists() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
||||
let value: Value = serde_json::from_str(&content)
|
||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件(原子写入)
|
||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
||||
|
||||
atomic_write(path, json.as_bytes())
|
||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 合并两个 JSON 对象(深度合并)
|
||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
||||
for (key, value) in overlay_obj {
|
||||
if let Some(base_value) = base_obj.get_mut(key) {
|
||||
// 如果两边都是对象,递归合并
|
||||
if base_value.is_object() && value.is_object() {
|
||||
Self::merge_json(base_value, value);
|
||||
} else {
|
||||
// 否则直接覆盖
|
||||
*base_value = value.clone();
|
||||
}
|
||||
} else {
|
||||
// 键不存在,直接插入
|
||||
base_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装 Markdown 文件(通用)
|
||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
||||
let dir = self.config_dir.join(subdir);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
||||
|
||||
let filename = if name.ends_with(".md") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.md")
|
||||
};
|
||||
|
||||
let file_path = dir.join(&filename);
|
||||
|
||||
atomic_write(&file_path, content.as_bytes())
|
||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
||||
|
||||
log::info!("已安装 Claude {}: {}", subdir, file_path.display());
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// 获取 Claude settings.json 路径
|
||||
fn get_settings_path(&self) -> PathBuf {
|
||||
crate::config::get_claude_settings_path()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppAdapter for ClaudeAdapter {
|
||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "agents", name)
|
||||
}
|
||||
|
||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "commands", name)
|
||||
}
|
||||
|
||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
||||
let mcp_path = get_claude_mcp_path();
|
||||
|
||||
// 读取现有 MCP 配置
|
||||
let mut current = Self::read_json_file(&mcp_path)?;
|
||||
|
||||
// 确保 mcpServers 字段存在
|
||||
if !current.is_object() {
|
||||
current = serde_json::json!({});
|
||||
}
|
||||
if current.get("mcpServers").is_none() {
|
||||
current["mcpServers"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
// 合并新的 MCP 服务器配置
|
||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
||||
Self::merge_json(mcp_servers, mcp_config);
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_json_file(&mcp_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Claude MCP 配置到: {}", mcp_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_setting(&self, setting_config: &Value) -> Result<()> {
|
||||
let settings_path = self.get_settings_path();
|
||||
|
||||
// 读取现有配置
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
// 确保 permissions 字段存在
|
||||
if !current.is_object() {
|
||||
current = serde_json::json!({});
|
||||
}
|
||||
if current.get("permissions").is_none() {
|
||||
current["permissions"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
// 合并新的 permissions 配置
|
||||
if let Some(permissions) = current.get_mut("permissions") {
|
||||
Self::merge_json(permissions, setting_config);
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Claude Setting 配置到: {}", settings_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_hook(&self, hook_config: &Value) -> Result<()> {
|
||||
let settings_path = self.get_settings_path();
|
||||
|
||||
// 读取现有配置
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
// 确保 hooks 字段存在
|
||||
if !current.is_object() {
|
||||
current = serde_json::json!({});
|
||||
}
|
||||
if current.get("hooks").is_none() {
|
||||
current["hooks"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
// 合并新的 hooks 配置
|
||||
if let Some(hooks) = current.get_mut("hooks") {
|
||||
Self::merge_json(hooks, hook_config);
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Claude Hook 配置到: {}", settings_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
||||
match component_type.to_lowercase().as_str() {
|
||||
"agent" => {
|
||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Claude Agent: {}", path.display());
|
||||
}
|
||||
}
|
||||
"command" => {
|
||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Claude Command: {}", path.display());
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let mcp_path = get_claude_mcp_path();
|
||||
let mut current = Self::read_json_file(&mcp_path)?;
|
||||
|
||||
if let Some(mcp_servers) = current
|
||||
.get_mut("mcpServers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
mcp_servers.remove(name);
|
||||
Self::write_json_file(&mcp_path, ¤t)?;
|
||||
log::info!("已卸载 Claude MCP 服务器: {name}");
|
||||
}
|
||||
}
|
||||
"setting" => {
|
||||
let settings_path = self.get_settings_path();
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
if let Some(permissions) = current
|
||||
.get_mut("permissions")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
permissions.remove(name);
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
log::info!("已卸载 Claude Setting: {name}");
|
||||
}
|
||||
}
|
||||
"hook" => {
|
||||
let settings_path = self.get_settings_path();
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
if let Some(hooks) = current.get_mut("hooks").and_then(|v| v.as_object_mut()) {
|
||||
hooks.remove(name);
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
log::info!("已卸载 Claude Hook: {name}");
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!("不支持的组件类型: {component_type}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> PathBuf {
|
||||
self.config_dir.clone()
|
||||
}
|
||||
|
||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
||||
matches!(
|
||||
component_type.to_lowercase().as_str(),
|
||||
"agent" | "command" | "mcp" | "setting" | "hook"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClaudeAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
//! Codex 应用适配器
|
||||
//!
|
||||
//! 部分支持:
|
||||
//! - Agent → `~/.codex/agents/{name}.md`
|
||||
//! - Command → `~/.codex/commands/{name}.md`
|
||||
//! - MCP → 合并到 `~/.codex/config.toml` 的 [mcp_servers] 表
|
||||
//! - Setting/Hook → 不支持(Codex 不支持这些功能)
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::AppAdapter;
|
||||
use crate::codex_config::get_codex_config_dir;
|
||||
use crate::config::{atomic_write, write_text_file};
|
||||
|
||||
/// Codex 应用适配器
|
||||
pub struct CodexAdapter {
|
||||
config_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl CodexAdapter {
|
||||
/// 创建新的 Codex 适配器实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config_dir: get_codex_config_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装 Markdown 文件(通用)
|
||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
||||
let dir = self.config_dir.join(subdir);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
||||
|
||||
let filename = if name.ends_with(".md") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.md")
|
||||
};
|
||||
|
||||
let file_path = dir.join(&filename);
|
||||
|
||||
atomic_write(&file_path, content.as_bytes())
|
||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
||||
|
||||
log::info!("已安装 Codex {}: {}", subdir, file_path.display());
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// 获取 Codex config.toml 路径
|
||||
fn get_config_toml_path(&self) -> PathBuf {
|
||||
crate::codex_config::get_codex_config_path()
|
||||
}
|
||||
|
||||
/// 读取 TOML 配置文件
|
||||
fn read_toml_file(path: &PathBuf) -> Result<toml::Table> {
|
||||
if !path.exists() {
|
||||
return Ok(toml::Table::new());
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
||||
let table: toml::Table = toml::from_str(&content)
|
||||
.with_context(|| format!("解析 TOML 失败: {}", path.display()))?;
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
/// 写入 TOML 配置文件(原子写入)
|
||||
fn write_toml_file(path: &Path, table: &toml::Table) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let toml_string = toml::to_string_pretty(table).context("序列化 TOML 失败")?;
|
||||
|
||||
write_text_file(path, &toml_string)
|
||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 JSON MCP 配置转换为 TOML 格式
|
||||
fn json_mcp_to_toml(json_config: &Value) -> Result<toml::Table> {
|
||||
let mut mcp_servers = toml::Table::new();
|
||||
|
||||
if let Some(obj) = json_config.as_object() {
|
||||
for (server_id, server_spec) in obj {
|
||||
let mut server_table = toml::Table::new();
|
||||
|
||||
if let Some(spec_obj) = server_spec.as_object() {
|
||||
// type 字段(默认 stdio)
|
||||
let server_type = spec_obj
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("stdio");
|
||||
server_table.insert(
|
||||
"type".to_string(),
|
||||
toml::Value::String(server_type.to_string()),
|
||||
);
|
||||
|
||||
match server_type {
|
||||
"stdio" => {
|
||||
// command 字段(必需)
|
||||
if let Some(cmd) = spec_obj.get("command").and_then(|v| v.as_str()) {
|
||||
server_table.insert(
|
||||
"command".to_string(),
|
||||
toml::Value::String(cmd.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// args 字段(可选)
|
||||
if let Some(args) = spec_obj.get("args").and_then(|v| v.as_array()) {
|
||||
let toml_args: Vec<toml::Value> = args
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| toml::Value::String(s.to_string()))
|
||||
.collect();
|
||||
if !toml_args.is_empty() {
|
||||
server_table
|
||||
.insert("args".to_string(), toml::Value::Array(toml_args));
|
||||
}
|
||||
}
|
||||
|
||||
// env 字段(可选)
|
||||
if let Some(env) = spec_obj.get("env").and_then(|v| v.as_object()) {
|
||||
let mut env_table = toml::Table::new();
|
||||
for (key, value) in env {
|
||||
if let Some(val_str) = value.as_str() {
|
||||
env_table.insert(
|
||||
key.clone(),
|
||||
toml::Value::String(val_str.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !env_table.is_empty() {
|
||||
server_table
|
||||
.insert("env".to_string(), toml::Value::Table(env_table));
|
||||
}
|
||||
}
|
||||
|
||||
// cwd 字段(可选)
|
||||
if let Some(cwd) = spec_obj.get("cwd").and_then(|v| v.as_str()) {
|
||||
server_table.insert(
|
||||
"cwd".to_string(),
|
||||
toml::Value::String(cwd.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
"http" | "sse" => {
|
||||
// url 字段(必需)
|
||||
if let Some(url) = spec_obj.get("url").and_then(|v| v.as_str()) {
|
||||
server_table.insert(
|
||||
"url".to_string(),
|
||||
toml::Value::String(url.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// http_headers 字段(可选)
|
||||
if let Some(headers) =
|
||||
spec_obj.get("http_headers").and_then(|v| v.as_object())
|
||||
{
|
||||
let mut headers_table = toml::Table::new();
|
||||
for (key, value) in headers {
|
||||
if let Some(val_str) = value.as_str() {
|
||||
headers_table.insert(
|
||||
key.clone(),
|
||||
toml::Value::String(val_str.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !headers_table.is_empty() {
|
||||
server_table.insert(
|
||||
"http_headers".to_string(),
|
||||
toml::Value::Table(headers_table),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
mcp_servers.insert(server_id.clone(), toml::Value::Table(server_table));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mcp_servers)
|
||||
}
|
||||
}
|
||||
|
||||
impl AppAdapter for CodexAdapter {
|
||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "agents", name)
|
||||
}
|
||||
|
||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "commands", name)
|
||||
}
|
||||
|
||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
||||
let config_path = self.get_config_toml_path();
|
||||
|
||||
// 读取现有 TOML 配置
|
||||
let mut current = Self::read_toml_file(&config_path)?;
|
||||
|
||||
// 确保 mcp_servers 表存在
|
||||
if !current.contains_key("mcp_servers") {
|
||||
current.insert(
|
||||
"mcp_servers".to_string(),
|
||||
toml::Value::Table(toml::Table::new()),
|
||||
);
|
||||
}
|
||||
|
||||
// 转换 JSON MCP 配置到 TOML
|
||||
let new_mcp_servers = Self::json_mcp_to_toml(mcp_config)?;
|
||||
|
||||
// 合并 MCP 服务器配置
|
||||
if let Some(mcp_servers) = current
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
for (server_id, server_config) in new_mcp_servers {
|
||||
mcp_servers.insert(server_id, server_config);
|
||||
}
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_toml_file(&config_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Codex MCP 配置到: {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
||||
bail!("Codex 不支持 Setting 配置")
|
||||
}
|
||||
|
||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
||||
bail!("Codex 不支持 Hook 配置")
|
||||
}
|
||||
|
||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
||||
match component_type.to_lowercase().as_str() {
|
||||
"agent" => {
|
||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Codex Agent: {}", path.display());
|
||||
}
|
||||
}
|
||||
"command" => {
|
||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Codex Command: {}", path.display());
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let config_path = self.get_config_toml_path();
|
||||
let mut current = Self::read_toml_file(&config_path)?;
|
||||
|
||||
if let Some(mcp_servers) = current
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
mcp_servers.remove(name);
|
||||
Self::write_toml_file(&config_path, ¤t)?;
|
||||
log::info!("已卸载 Codex MCP 服务器: {name}");
|
||||
}
|
||||
}
|
||||
"setting" | "hook" => {
|
||||
bail!("Codex 不支持 {component_type} 组件类型")
|
||||
}
|
||||
_ => bail!("不支持的组件类型: {component_type}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> PathBuf {
|
||||
self.config_dir.clone()
|
||||
}
|
||||
|
||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
||||
matches!(
|
||||
component_type.to_lowercase().as_str(),
|
||||
"agent" | "command" | "mcp"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodexAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
//! Gemini 应用适配器
|
||||
//!
|
||||
//! 部分支持:
|
||||
//! - Agent → `~/.gemini/agents/{name}.md`
|
||||
//! - Command → `~/.gemini/commands/{name}.md`
|
||||
//! - MCP → 合并到 `~/.gemini/settings.json` 的 mcpServers 字段
|
||||
//! - Setting/Hook → 不支持(Gemini 不支持这些功能)
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::AppAdapter;
|
||||
use crate::config::atomic_write;
|
||||
use crate::gemini_config::{get_gemini_dir, get_gemini_settings_path};
|
||||
|
||||
/// Gemini 应用适配器
|
||||
pub struct GeminiAdapter {
|
||||
config_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl GeminiAdapter {
|
||||
/// 创建新的 Gemini 适配器实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config_dir: get_gemini_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 JSON 配置文件
|
||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
||||
if !path.exists() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
||||
let value: Value = serde_json::from_str(&content)
|
||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件(原子写入)
|
||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
||||
|
||||
atomic_write(path, json.as_bytes())
|
||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 合并两个 JSON 对象(深度合并)
|
||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
||||
for (key, value) in overlay_obj {
|
||||
if let Some(base_value) = base_obj.get_mut(key) {
|
||||
// 如果两边都是对象,递归合并
|
||||
if base_value.is_object() && value.is_object() {
|
||||
Self::merge_json(base_value, value);
|
||||
} else {
|
||||
// 否则直接覆盖
|
||||
*base_value = value.clone();
|
||||
}
|
||||
} else {
|
||||
// 键不存在,直接插入
|
||||
base_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装 Markdown 文件(通用)
|
||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
||||
let dir = self.config_dir.join(subdir);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
||||
|
||||
let filename = if name.ends_with(".md") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.md")
|
||||
};
|
||||
|
||||
let file_path = dir.join(&filename);
|
||||
|
||||
atomic_write(&file_path, content.as_bytes())
|
||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
||||
|
||||
log::info!("已安装 Gemini {}: {}", subdir, file_path.display());
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// 获取 Gemini settings.json 路径
|
||||
fn get_settings_path(&self) -> PathBuf {
|
||||
get_gemini_settings_path()
|
||||
}
|
||||
|
||||
/// 转换 MCP 配置为 Gemini 格式
|
||||
///
|
||||
/// Gemini 使用特殊格式:
|
||||
/// - HTTP 类型:使用 `httpUrl` 而不是 `url` + `type: "http"`
|
||||
/// - SSE/stdio 类型:保持标准格式
|
||||
fn transform_mcp_to_gemini(mcp_config: &Value) -> Result<Value> {
|
||||
let mut transformed = mcp_config.clone();
|
||||
|
||||
if let Some(obj) = transformed.as_object_mut() {
|
||||
for (_server_id, server_spec) in obj.iter_mut() {
|
||||
if let Some(spec_obj) = server_spec.as_object_mut() {
|
||||
// 检查是否为 HTTP 类型
|
||||
let is_http = spec_obj
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|t| t == "http")
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_http {
|
||||
// 将 url 字段转换为 httpUrl
|
||||
if let Some(url) = spec_obj.remove("url") {
|
||||
spec_obj.insert("httpUrl".to_string(), url);
|
||||
}
|
||||
// 移除 type 字段(Gemini 不需要显式指定 type)
|
||||
spec_obj.remove("type");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(transformed)
|
||||
}
|
||||
}
|
||||
|
||||
impl AppAdapter for GeminiAdapter {
|
||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "agents", name)
|
||||
}
|
||||
|
||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "commands", name)
|
||||
}
|
||||
|
||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
||||
let settings_path = self.get_settings_path();
|
||||
|
||||
// 读取现有配置
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
// 确保 mcpServers 字段存在
|
||||
if !current.is_object() {
|
||||
current = serde_json::json!({});
|
||||
}
|
||||
if current.get("mcpServers").is_none() {
|
||||
current["mcpServers"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
// 转换 MCP 配置为 Gemini 格式
|
||||
let transformed = Self::transform_mcp_to_gemini(mcp_config)?;
|
||||
|
||||
// 合并新的 MCP 服务器配置
|
||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
||||
Self::merge_json(mcp_servers, &transformed);
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Gemini MCP 配置到: {}", settings_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
||||
bail!("Gemini 不支持 Setting 配置")
|
||||
}
|
||||
|
||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
||||
bail!("Gemini 不支持 Hook 配置")
|
||||
}
|
||||
|
||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
||||
match component_type.to_lowercase().as_str() {
|
||||
"agent" => {
|
||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Gemini Agent: {}", path.display());
|
||||
}
|
||||
}
|
||||
"command" => {
|
||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Gemini Command: {}", path.display());
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let settings_path = self.get_settings_path();
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
if let Some(mcp_servers) = current
|
||||
.get_mut("mcpServers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
mcp_servers.remove(name);
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
log::info!("已卸载 Gemini MCP 服务器: {name}");
|
||||
}
|
||||
}
|
||||
"setting" | "hook" => {
|
||||
bail!("Gemini 不支持 {component_type} 组件类型")
|
||||
}
|
||||
_ => bail!("不支持的组件类型: {component_type}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> PathBuf {
|
||||
self.config_dir.clone()
|
||||
}
|
||||
|
||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
||||
matches!(
|
||||
component_type.to_lowercase().as_str(),
|
||||
"agent" | "command" | "mcp"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GeminiAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//! 应用适配器模块
|
||||
//!
|
||||
//! 负责将 Template 组件安装到不同应用的配置目录中。
|
||||
//! 每个应用有独立的适配器实现,处理各自的配置格式和目录结构。
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use claude::ClaudeAdapter;
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
|
||||
/// 应用适配器 trait
|
||||
///
|
||||
/// 定义了将 Template 组件安装到应用配置目录的统一接口。
|
||||
/// 每个应用实现自己的适配器来处理特定的配置格式和目录结构。
|
||||
#[allow(dead_code)]
|
||||
pub trait AppAdapter: Send + Sync {
|
||||
/// 安装 Agent 到应用配置目录
|
||||
///
|
||||
/// # 参数
|
||||
/// - `content`: Agent 内容(Markdown 格式)
|
||||
/// - `name`: Agent 名称(用作文件名)
|
||||
///
|
||||
/// # 返回
|
||||
/// 安装后的文件路径
|
||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf>;
|
||||
|
||||
/// 安装 Command 到应用配置目录
|
||||
///
|
||||
/// # 参数
|
||||
/// - `content`: Command 内容(Markdown 格式)
|
||||
/// - `name`: Command 名称(用作文件名)
|
||||
///
|
||||
/// # 返回
|
||||
/// 安装后的文件路径
|
||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf>;
|
||||
|
||||
/// 安装 MCP 服务器配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `mcp_config`: MCP 服务器配置(JSON 对象)
|
||||
///
|
||||
/// # 说明
|
||||
/// 配置会合并到应用的 MCP 配置文件中,保留现有配置。
|
||||
fn install_mcp(&self, mcp_config: &serde_json::Value) -> Result<()>;
|
||||
|
||||
/// 安装 Setting (permissions)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `setting_config`: Setting 配置(JSON 对象)
|
||||
///
|
||||
/// # 说明
|
||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 permissions 字段。
|
||||
fn install_setting(&self, setting_config: &serde_json::Value) -> Result<()>;
|
||||
|
||||
/// 安装 Hook
|
||||
///
|
||||
/// # 参数
|
||||
/// - `hook_config`: Hook 配置(JSON 对象)
|
||||
///
|
||||
/// # 说明
|
||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 hooks 字段。
|
||||
fn install_hook(&self, hook_config: &serde_json::Value) -> Result<()>;
|
||||
|
||||
/// 卸载组件
|
||||
///
|
||||
/// # 参数
|
||||
/// - `component_type`: 组件类型(agent/command/mcp/setting/hook)
|
||||
/// - `name`: 组件名称或 ID
|
||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()>;
|
||||
|
||||
/// 获取配置目录路径
|
||||
fn config_dir(&self) -> PathBuf;
|
||||
|
||||
/// 检查组件类型是否支持
|
||||
///
|
||||
/// # 参数
|
||||
/// - `component_type`: 组件类型字符串
|
||||
///
|
||||
/// # 返回
|
||||
/// 如果应用支持该组件类型返回 true,否则返回 false
|
||||
#[allow(dead_code)]
|
||||
fn supports_component_type(&self, component_type: &str) -> bool;
|
||||
}
|
||||
|
||||
/// 创建应用适配器工厂函数
|
||||
///
|
||||
/// # 参数
|
||||
/// - `app_type`: 应用类型
|
||||
///
|
||||
/// # 返回
|
||||
/// 对应应用的适配器实例
|
||||
#[allow(dead_code)]
|
||||
pub fn create_adapter(app_type: &AppType) -> Box<dyn AppAdapter> {
|
||||
match app_type {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
}
|
||||
@@ -1,746 +0,0 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::{ComponentMetadata, ComponentType, TemplateComponent, TemplateRepo, TemplateService};
|
||||
|
||||
impl TemplateService {
|
||||
/// 刷新所有启用仓库的组件索引
|
||||
pub async fn refresh_index(&self, conn: &Connection) -> Result<()> {
|
||||
// 获取所有启用的仓库
|
||||
let repos = self.list_enabled_repos(conn)?;
|
||||
|
||||
if repos.is_empty() {
|
||||
log::info!("没有启用的模板仓库");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("开始刷新 {} 个模板仓库", repos.len());
|
||||
|
||||
// 并行扫描所有仓库
|
||||
let scan_tasks = repos.iter().map(|repo| self.scan_repo(repo));
|
||||
let results: Vec<Result<Vec<TemplateComponent>>> =
|
||||
futures::future::join_all(scan_tasks).await;
|
||||
|
||||
// 处理扫描结果
|
||||
let mut total_components = 0;
|
||||
for (repo, result) in repos.iter().zip(results.into_iter()) {
|
||||
match result {
|
||||
Ok(components) => {
|
||||
log::info!(
|
||||
"仓库 {}/{} 扫描到 {} 个组件",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
components.len()
|
||||
);
|
||||
|
||||
// 保存到数据库
|
||||
if let Err(e) = self.save_components(conn, &components) {
|
||||
log::error!("保存组件到数据库失败: {e}");
|
||||
} else {
|
||||
total_components += components.len();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("扫描仓库 {}/{} 失败: {}", repo.owner, repo.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("刷新完成,共索引 {total_components} 个组件");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描单个仓库
|
||||
pub async fn scan_repo(&self, repo: &TemplateRepo) -> Result<Vec<TemplateComponent>> {
|
||||
log::info!("开始扫描仓库: {}/{}", repo.owner, repo.name);
|
||||
|
||||
// 下载仓库(增加超时控制)
|
||||
let temp_dir = timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
self.download_repo(repo),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("下载仓库超时: {}/{}", repo.owner, repo.name))??;
|
||||
|
||||
let mut components = Vec::new();
|
||||
|
||||
// 扫描不同类型的组件
|
||||
self.scan_agents(&temp_dir, repo, &mut components)?;
|
||||
self.scan_commands(&temp_dir, repo, &mut components)?;
|
||||
self.scan_mcps(&temp_dir, repo, &mut components)?;
|
||||
self.scan_settings(&temp_dir, repo, &mut components)?;
|
||||
self.scan_hooks(&temp_dir, repo, &mut components)?;
|
||||
self.scan_skills(&temp_dir, repo, &mut components)?;
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
log::info!(
|
||||
"仓库 {}/{} 扫描完成,找到 {} 个组件",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
components.len()
|
||||
);
|
||||
|
||||
Ok(components)
|
||||
}
|
||||
|
||||
/// 下载仓库 ZIP
|
||||
async fn download_repo(&self, repo: &TemplateRepo) -> Result<PathBuf> {
|
||||
let temp_dir = tempfile::tempdir().context("创建临时目录失败")?;
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
let _ = temp_dir.keep();
|
||||
|
||||
// 尝试多个分支
|
||||
let branches = if repo.branch.is_empty() {
|
||||
vec!["main", "master"]
|
||||
} else {
|
||||
vec![repo.branch.as_str(), "main", "master"]
|
||||
};
|
||||
|
||||
let mut last_error = None;
|
||||
for branch in branches {
|
||||
let url = format!(
|
||||
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
||||
repo.owner, repo.name, branch
|
||||
);
|
||||
|
||||
log::debug!("尝试下载: {url}");
|
||||
match self.download_and_extract(&url, &temp_path).await {
|
||||
Ok(_) => {
|
||||
log::info!("成功下载仓库: {}/{} ({})", repo.owner, repo.name, branch);
|
||||
return Ok(temp_path);
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("下载分支 {branch} 失败: {e}");
|
||||
last_error = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow!("所有分支下载失败")))
|
||||
}
|
||||
|
||||
/// 下载并解压 ZIP
|
||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||
// 下载 ZIP
|
||||
let response = self.client().get(url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("下载失败: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
// 解压
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor)?;
|
||||
|
||||
// 获取根目录名称
|
||||
let root_name = if !archive.is_empty() {
|
||||
let first_file = archive.by_index(0)?;
|
||||
let name = first_file.name();
|
||||
name.split('/').next().unwrap_or("").to_string()
|
||||
} else {
|
||||
return Err(anyhow!("空的压缩包"));
|
||||
};
|
||||
|
||||
// 解压所有文件
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let file_path = file.name();
|
||||
|
||||
// 跳过根目录,直接提取内容
|
||||
let relative_path =
|
||||
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
||||
stripped
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if relative_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let outpath = dest.join(relative_path);
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath)?;
|
||||
std::io::copy(&mut file, &mut outfile)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Agents
|
||||
fn scan_agents(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
// 尝试多个可能的路径
|
||||
let paths = [
|
||||
base_dir.join("cli-tool").join("components").join("agents"),
|
||||
base_dir.join("src").join("agents"),
|
||||
base_dir.join("components").join("agents"),
|
||||
];
|
||||
for agents_dir in paths {
|
||||
if agents_dir.exists() {
|
||||
self.scan_markdown_components(
|
||||
&agents_dir,
|
||||
base_dir,
|
||||
ComponentType::Agent,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Commands
|
||||
fn scan_commands(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir
|
||||
.join("cli-tool")
|
||||
.join("components")
|
||||
.join("commands"),
|
||||
base_dir.join("src").join("commands"),
|
||||
base_dir.join("components").join("commands"),
|
||||
];
|
||||
for commands_dir in paths {
|
||||
if commands_dir.exists() {
|
||||
self.scan_markdown_components(
|
||||
&commands_dir,
|
||||
base_dir,
|
||||
ComponentType::Command,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 MCPs
|
||||
fn scan_mcps(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir.join("cli-tool").join("components").join("mcps"),
|
||||
base_dir.join("src").join("mcp"),
|
||||
base_dir.join("components").join("mcps"),
|
||||
];
|
||||
for mcps_dir in paths {
|
||||
if mcps_dir.exists() {
|
||||
self.scan_json_components(
|
||||
&mcps_dir,
|
||||
base_dir,
|
||||
ComponentType::Mcp,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Settings
|
||||
fn scan_settings(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir
|
||||
.join("cli-tool")
|
||||
.join("components")
|
||||
.join("settings"),
|
||||
base_dir.join("src").join("settings"),
|
||||
base_dir.join("components").join("settings"),
|
||||
];
|
||||
for settings_dir in paths {
|
||||
if settings_dir.exists() {
|
||||
self.scan_json_components(
|
||||
&settings_dir,
|
||||
base_dir,
|
||||
ComponentType::Setting,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Hooks
|
||||
fn scan_hooks(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir.join("cli-tool").join("components").join("hooks"),
|
||||
base_dir.join("src").join("hooks"),
|
||||
base_dir.join("components").join("hooks"),
|
||||
];
|
||||
for hooks_dir in paths {
|
||||
if hooks_dir.exists() {
|
||||
self.scan_json_components(
|
||||
&hooks_dir,
|
||||
base_dir,
|
||||
ComponentType::Hook,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Skills
|
||||
fn scan_skills(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir.join("cli-tool").join("components").join("skills"),
|
||||
base_dir.join("src").join("skills"),
|
||||
base_dir.join("components").join("skills"),
|
||||
];
|
||||
for skills_dir in paths {
|
||||
if skills_dir.exists() {
|
||||
self.scan_skills_recursive(&skills_dir, base_dir, repo, components)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Markdown 组件(Agent/Command)
|
||||
fn scan_markdown_components(
|
||||
&self,
|
||||
dir: &Path,
|
||||
base_dir: &Path,
|
||||
component_type: ComponentType,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||||
if let Ok(component) =
|
||||
self.parse_markdown_component(&path, base_dir, component_type.clone(), repo)
|
||||
{
|
||||
components.push(component);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
// 递归扫描子目录(用于分类)
|
||||
self.scan_markdown_components(
|
||||
&path,
|
||||
base_dir,
|
||||
component_type.clone(),
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 JSON 组件(MCP/Setting/Hook)
|
||||
fn scan_json_components(
|
||||
&self,
|
||||
dir: &Path,
|
||||
base_dir: &Path,
|
||||
component_type: ComponentType,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
if let Ok(component) =
|
||||
self.parse_json_component(&path, base_dir, component_type.clone(), repo)
|
||||
{
|
||||
components.push(component);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
// 递归扫描子目录(用于分类)
|
||||
self.scan_json_components(
|
||||
&path,
|
||||
base_dir,
|
||||
component_type.clone(),
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 递归扫描技能目录
|
||||
fn scan_skills_recursive(
|
||||
&self,
|
||||
current_dir: &Path,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let skill_md = current_dir.join("SKILL.md");
|
||||
|
||||
if skill_md.exists() {
|
||||
// 发现技能
|
||||
if let Ok(component) = self.parse_skill_component(&skill_md, base_dir, repo) {
|
||||
components.push(component);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 继续递归扫描子目录
|
||||
for entry in fs::read_dir(current_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
self.scan_skills_recursive(&path, base_dir, repo, components)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 Markdown 组件元数据
|
||||
fn parse_markdown_component(
|
||||
&self,
|
||||
path: &Path,
|
||||
base_dir: &Path,
|
||||
component_type: ComponentType,
|
||||
repo: &TemplateRepo,
|
||||
) -> Result<TemplateComponent> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let meta = self.parse_component_metadata(&content)?;
|
||||
|
||||
let file_name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// 提取分类(从目录结构)
|
||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
||||
|
||||
// 计算相对于仓库根目录的路径
|
||||
let relative_path = path
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: None,
|
||||
repo_id: repo.id.unwrap_or(0),
|
||||
component_type,
|
||||
category,
|
||||
name: meta.name.unwrap_or_else(|| file_name.to_string()),
|
||||
path: relative_path,
|
||||
description: meta.description,
|
||||
content_hash: Some(Self::calculate_hash(&content)),
|
||||
installed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析 JSON 组件元数据
|
||||
fn parse_json_component(
|
||||
&self,
|
||||
path: &Path,
|
||||
base_dir: &Path,
|
||||
component_type: ComponentType,
|
||||
repo: &TemplateRepo,
|
||||
) -> Result<TemplateComponent> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let json: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
let file_name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let name = json
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(file_name)
|
||||
.to_string();
|
||||
|
||||
let description = json
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
||||
|
||||
// 计算相对于仓库根目录的路径
|
||||
let relative_path = path
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: None,
|
||||
repo_id: repo.id.unwrap_or(0),
|
||||
component_type,
|
||||
category,
|
||||
name,
|
||||
path: relative_path,
|
||||
description,
|
||||
content_hash: Some(Self::calculate_hash(&content)),
|
||||
installed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析技能组件
|
||||
fn parse_skill_component(
|
||||
&self,
|
||||
skill_md: &Path,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
) -> Result<TemplateComponent> {
|
||||
let content = fs::read_to_string(skill_md)?;
|
||||
let meta = self.parse_component_metadata(&content)?;
|
||||
|
||||
let skill_dir = skill_md.parent().unwrap();
|
||||
let directory = skill_dir
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(skill_dir)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: None,
|
||||
repo_id: repo.id.unwrap_or(0),
|
||||
component_type: ComponentType::Skill,
|
||||
category: None,
|
||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||
path: directory,
|
||||
description: meta.description,
|
||||
content_hash: Some(Self::calculate_hash(&content)),
|
||||
installed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析组件元数据(从 front matter)
|
||||
pub fn parse_component_metadata(&self, content: &str) -> Result<ComponentMetadata> {
|
||||
// 移除 BOM
|
||||
let content = content.trim_start_matches('\u{feff}');
|
||||
|
||||
// 提取 YAML front matter
|
||||
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||||
if parts.len() < 3 {
|
||||
return Ok(ComponentMetadata {
|
||||
name: None,
|
||||
description: None,
|
||||
tools: None,
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
|
||||
let front_matter = parts[1].trim();
|
||||
let meta: ComponentMetadata =
|
||||
serde_yaml::from_str(front_matter).unwrap_or(ComponentMetadata {
|
||||
name: None,
|
||||
description: None,
|
||||
tools: None,
|
||||
model: None,
|
||||
});
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// 提取分类(从路径)
|
||||
fn extract_category(&self, path: &Path, base: &str) -> Option<String> {
|
||||
let path_str = path.to_string_lossy();
|
||||
if let Some(pos) = path_str.find(base) {
|
||||
let after_base = &path_str[pos + base.len()..];
|
||||
let parts: Vec<&str> = after_base.split('/').filter(|s| !s.is_empty()).collect();
|
||||
if parts.len() > 1 {
|
||||
return Some(parts[0].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 计算内容哈希
|
||||
fn calculate_hash(content: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// 保存组件到数据库
|
||||
fn save_components(&self, conn: &Connection, components: &[TemplateComponent]) -> Result<()> {
|
||||
for component in components {
|
||||
// 检查是否已存在(通过 repo_id + component_type + path)
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM template_components
|
||||
WHERE repo_id = ?1 AND component_type = ?2 AND path = ?3",
|
||||
params![
|
||||
component.repo_id,
|
||||
component.component_type.as_str(),
|
||||
&component.path
|
||||
],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(id) = existing {
|
||||
// 更新现有组件
|
||||
conn.execute(
|
||||
"UPDATE template_components
|
||||
SET category = ?1, name = ?2, description = ?3, content_hash = ?4, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
&component.category,
|
||||
&component.name,
|
||||
&component.description,
|
||||
&component.content_hash,
|
||||
id
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
// 插入新组件
|
||||
conn.execute(
|
||||
"INSERT INTO template_components (repo_id, component_type, category, name, path, description, content_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
component.repo_id,
|
||||
component.component_type.as_str(),
|
||||
&component.category,
|
||||
&component.name,
|
||||
&component.path,
|
||||
&component.description,
|
||||
&component.content_hash
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 列出组件(支持过滤和分页)
|
||||
#[allow(dead_code)]
|
||||
pub fn list_components(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
component_type: Option<ComponentType>,
|
||||
category: Option<String>,
|
||||
search: Option<String>,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<super::PaginatedResult<TemplateComponent>> {
|
||||
let mut where_clauses = Vec::new();
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ct) = &component_type {
|
||||
where_clauses.push("component_type = ?");
|
||||
params.push(Box::new(ct.as_str().to_string()));
|
||||
}
|
||||
|
||||
if let Some(cat) = &category {
|
||||
where_clauses.push("category = ?");
|
||||
params.push(Box::new(cat.clone()));
|
||||
}
|
||||
|
||||
if let Some(s) = &search {
|
||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
||||
let search_pattern = format!("%{s}%");
|
||||
params.push(Box::new(search_pattern.clone()));
|
||||
params.push(Box::new(search_pattern));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
// 获取总数
|
||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let total: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
|
||||
|
||||
// 获取分页数据
|
||||
let offset = (page - 1) * page_size;
|
||||
let query_sql = format!(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
{where_sql}
|
||||
ORDER BY name
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
params.push(Box::new(page_size as i64));
|
||||
params.push(Box::new(offset as i64));
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&query_sql)?;
|
||||
let components = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(super::PaginatedResult {
|
||||
items: components,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::fs;
|
||||
|
||||
use super::{
|
||||
BatchInstallResult, ComponentDetail, ComponentType, InstalledComponent, TemplateComponent,
|
||||
TemplateService,
|
||||
};
|
||||
|
||||
impl TemplateService {
|
||||
/// 获取组件详情(含完整内容)
|
||||
pub async fn get_component(&self, conn: &Connection, id: i64) -> Result<ComponentDetail> {
|
||||
// 查询组件基本信息
|
||||
let component: TemplateComponent = conn.query_row(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
// 查询仓库信息
|
||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
||||
params![component.repo_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
|
||||
// 构建 README URL
|
||||
let readme_url = format!(
|
||||
"https://github.com/{}/{}/tree/{}/{}",
|
||||
repo_owner, repo_name, branch, component.path
|
||||
);
|
||||
|
||||
// 下载并读取组件内容
|
||||
let content = self
|
||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
||||
.await?;
|
||||
|
||||
Ok(ComponentDetail {
|
||||
component,
|
||||
content,
|
||||
repo_owner,
|
||||
repo_name,
|
||||
repo_branch: branch,
|
||||
readme_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// 下载组件内容
|
||||
async fn download_component_content(
|
||||
&self,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
branch: &str,
|
||||
path: &str,
|
||||
) -> Result<String> {
|
||||
let url = format!("https://raw.githubusercontent.com/{owner}/{name}/{branch}/{path}");
|
||||
|
||||
let response = self.client().get(&url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("下载组件内容失败: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let content = response.text().await?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// 安装组件到指定应用
|
||||
pub async fn install_component(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
app_type: &str,
|
||||
) -> Result<()> {
|
||||
// 获取组件详情
|
||||
let detail = self.get_component(conn, id).await?;
|
||||
|
||||
// 根据组件类型执行不同的安装逻辑
|
||||
match detail.component.component_type {
|
||||
ComponentType::Agent => {
|
||||
self.install_agent(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Command => {
|
||||
self.install_command(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Mcp => {
|
||||
self.install_mcp(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Setting => {
|
||||
self.install_setting(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Hook => {
|
||||
self.install_hook(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Skill => {
|
||||
self.install_skill(&detail, app_type).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录安装状态
|
||||
self.record_installation(conn, &detail.component, app_type)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Agent
|
||||
async fn install_agent(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let agents_dir = config_dir.join("agents");
|
||||
fs::create_dir_all(&agents_dir)?;
|
||||
|
||||
let file_name = format!("{}.md", detail.component.name);
|
||||
let dest_path = agents_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!("Agent 已安装: {}", dest_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Command
|
||||
async fn install_command(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let commands_dir = config_dir.join("commands");
|
||||
fs::create_dir_all(&commands_dir)?;
|
||||
|
||||
let file_name = format!("{}.md", detail.component.name);
|
||||
let dest_path = commands_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!("Command 已安装: {}", dest_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 MCP 服务器
|
||||
/// MCP 配置保存为独立 JSON 文件到 mcps/ 目录,不会修改原有 .mcp.json
|
||||
async fn install_mcp(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let mcps_dir = config_dir.join("mcps");
|
||||
fs::create_dir_all(&mcps_dir)?;
|
||||
|
||||
// 保存为独立的 JSON 文件(保留原始格式,包含 mcpServers 结构)
|
||||
let file_name = format!("{}.json", detail.component.name);
|
||||
let dest_path = mcps_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!(
|
||||
"MCP 配置已保存: {} (可手动合并到 .mcp.json)",
|
||||
dest_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Setting
|
||||
/// Setting 配置保存为独立 JSON 文件到 settings/ 目录,不会修改原有 settings.json
|
||||
/// 原始格式包含 permissions 等配置,可手动合并
|
||||
async fn install_setting(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let settings_dir = config_dir.join("settings");
|
||||
fs::create_dir_all(&settings_dir)?;
|
||||
|
||||
// 保存为独立的 JSON 文件(保留原始格式,包含 permissions 等结构)
|
||||
let file_name = format!("{}.json", detail.component.name);
|
||||
let dest_path = settings_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!(
|
||||
"Setting 配置已保存: {} (可手动合并到 settings.json)",
|
||||
dest_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Hook
|
||||
/// Hook 配置保存为独立 JSON 文件到 hooks/ 目录,不会修改原有 settings.json
|
||||
/// 原始格式包含 hooks 对象(如 PostToolUse 等),可手动合并
|
||||
async fn install_hook(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let hooks_dir = config_dir.join("hooks");
|
||||
fs::create_dir_all(&hooks_dir)?;
|
||||
|
||||
// 保存为独立的 JSON 文件(保留原始格式,包含 hooks 结构)
|
||||
let file_name = format!("{}.json", detail.component.name);
|
||||
let dest_path = hooks_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!(
|
||||
"Hook 配置已保存: {} (可手动合并到 settings.json 的 hooks 字段)",
|
||||
dest_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Skill
|
||||
/// Skill 是一个目录结构,包含 SKILL.md 和可能的子目录(如 reference/, scripts/)
|
||||
/// 使用 GitHub API 递归下载整个目录
|
||||
async fn install_skill(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let skills_dir = config_dir.join("skills");
|
||||
fs::create_dir_all(&skills_dir)?;
|
||||
|
||||
let skill_dir = skills_dir.join(&detail.component.name);
|
||||
fs::create_dir_all(&skill_dir)?;
|
||||
|
||||
// 首先保存 SKILL.md(已下载的内容)
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
fs::write(&skill_md, &detail.content)?;
|
||||
|
||||
// 尝试下载整个 skill 目录的其他文件
|
||||
// 构建 GitHub API URL 来获取目录内容
|
||||
let api_url = format!(
|
||||
"https://api.github.com/repos/{}/{}/contents/{}",
|
||||
detail.repo_owner,
|
||||
detail.repo_name,
|
||||
detail.component.path.trim_end_matches("/SKILL.md")
|
||||
);
|
||||
|
||||
// 递归下载目录内容
|
||||
if let Err(e) = self
|
||||
.download_skill_directory(&api_url, &skill_dir, &detail.repo_branch)
|
||||
.await
|
||||
{
|
||||
log::warn!("下载 Skill 附加文件失败: {e},仅安装 SKILL.md");
|
||||
}
|
||||
|
||||
log::info!("Skill 已安装: {}", skill_dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 递归下载 Skill 目录内容
|
||||
async fn download_skill_directory(
|
||||
&self,
|
||||
api_url: &str,
|
||||
target_dir: &std::path::Path,
|
||||
branch: &str,
|
||||
) -> Result<()> {
|
||||
let response = self
|
||||
.client()
|
||||
.get(api_url)
|
||||
.header("Accept", "application/vnd.github.v3+json")
|
||||
.header("User-Agent", "cc-switch")
|
||||
.query(&[("ref", branch)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("GitHub API 请求失败: {}", response.status());
|
||||
}
|
||||
|
||||
let contents: Vec<serde_json::Value> = response.json().await?;
|
||||
|
||||
for item in contents {
|
||||
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let item_name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// 跳过 SKILL.md(已经下载)
|
||||
if item_name == "SKILL.md" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if item_type == "file" {
|
||||
// 下载文件
|
||||
if let Some(download_url) = item.get("download_url").and_then(|v| v.as_str()) {
|
||||
let file_response = self.client().get(download_url).send().await?;
|
||||
if file_response.status().is_success() {
|
||||
let content = file_response.text().await?;
|
||||
let file_path = target_dir.join(item_name);
|
||||
fs::write(&file_path, &content)?;
|
||||
log::debug!("下载文件: {}", file_path.display());
|
||||
}
|
||||
}
|
||||
} else if item_type == "dir" {
|
||||
// 递归下载子目录
|
||||
if let Some(sub_url) = item.get("url").and_then(|v| v.as_str()) {
|
||||
let sub_dir = target_dir.join(item_name);
|
||||
fs::create_dir_all(&sub_dir)?;
|
||||
// 递归调用,使用 Box::pin 处理异步递归
|
||||
Box::pin(self.download_skill_directory(sub_url, &sub_dir, branch)).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记录安装状态
|
||||
fn record_installation(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
component: &TemplateComponent,
|
||||
app_type: &str,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO installed_components (component_id, component_type, name, path, app_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
component.id,
|
||||
component.component_type.as_str(),
|
||||
&component.name,
|
||||
&component.path,
|
||||
app_type
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 卸载组件
|
||||
pub fn uninstall_component(&self, conn: &Connection, id: i64, app_type: &str) -> Result<()> {
|
||||
// 查询组件信息
|
||||
let component: TemplateComponent = conn.query_row(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
// 删除文件
|
||||
match component.component_type {
|
||||
ComponentType::Agent => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("agents")
|
||||
.join(format!("{}.md", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Command => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("commands")
|
||||
.join(format!("{}.md", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Skill => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let skill_dir = config_dir.join("skills").join(&component.name);
|
||||
if skill_dir.exists() {
|
||||
fs::remove_dir_all(&skill_dir)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Mcp => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("mcps")
|
||||
.join(format!("{}.json", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Setting => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("settings")
|
||||
.join(format!("{}.json", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Hook => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("hooks")
|
||||
.join(format!("{}.json", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除安装记录
|
||||
conn.execute(
|
||||
"DELETE FROM installed_components
|
||||
WHERE component_id = ?1 AND app_type = ?2",
|
||||
params![id, app_type],
|
||||
)?;
|
||||
|
||||
log::info!("组件已卸载: {}", component.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量安装组件
|
||||
pub async fn batch_install(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
ids: Vec<i64>,
|
||||
app_type: &str,
|
||||
) -> Result<BatchInstallResult> {
|
||||
let mut success = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
|
||||
for id in ids {
|
||||
match self.install_component(conn, id, app_type).await {
|
||||
Ok(_) => success.push(id),
|
||||
Err(e) => failed.push((id, e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BatchInstallResult { success, failed })
|
||||
}
|
||||
|
||||
/// 列出已安装的组件
|
||||
#[allow(dead_code)]
|
||||
pub fn list_installed(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<InstalledComponent>> {
|
||||
let (sql, params): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type {
|
||||
(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
WHERE app_type = ?
|
||||
ORDER BY installed_at DESC"
|
||||
.to_string(),
|
||||
vec![Box::new(at.to_string())],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
ORDER BY installed_at DESC"
|
||||
.to_string(),
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let components = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok(InstalledComponent {
|
||||
id: Some(row.get(0)?),
|
||||
component_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
name: row.get(3)?,
|
||||
path: row.get(4)?,
|
||||
app_type: row.get(5)?,
|
||||
installed_at: row
|
||||
.get::<_, String>(6)?
|
||||
.parse()
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(components)
|
||||
}
|
||||
|
||||
/// 预览组件内容(仅获取内容,不进行安装)
|
||||
pub async fn preview_content(&self, conn: &Connection, id: i64) -> Result<String> {
|
||||
// 查询组件基本信息
|
||||
let component: TemplateComponent = conn.query_row(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
// 查询仓库信息
|
||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
||||
params![component.repo_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
|
||||
// 下载并读取组件内容
|
||||
let content = self
|
||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
||||
.await?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub mod adapters;
|
||||
pub mod index;
|
||||
pub mod installer;
|
||||
pub mod repo;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use adapters::{create_adapter, AppAdapter};
|
||||
|
||||
/// 组件类型枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ComponentType {
|
||||
Agent,
|
||||
Command,
|
||||
Mcp,
|
||||
Setting,
|
||||
Hook,
|
||||
Skill,
|
||||
}
|
||||
|
||||
impl ComponentType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ComponentType::Agent => "agent",
|
||||
ComponentType::Command => "command",
|
||||
ComponentType::Mcp => "mcp",
|
||||
ComponentType::Setting => "setting",
|
||||
ComponentType::Hook => "hook",
|
||||
ComponentType::Skill => "skill",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"agent" => Some(ComponentType::Agent),
|
||||
"command" => Some(ComponentType::Command),
|
||||
"mcp" => Some(ComponentType::Mcp),
|
||||
"setting" => Some(ComponentType::Setting),
|
||||
"hook" => Some(ComponentType::Hook),
|
||||
"skill" => Some(ComponentType::Skill),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模板仓库
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateRepo {
|
||||
pub id: Option<i64>,
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
pub branch: String,
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
#[serde(rename = "updatedAt")]
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl TemplateRepo {
|
||||
pub fn new(owner: String, name: String, branch: String) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
owner,
|
||||
name,
|
||||
branch,
|
||||
enabled: true,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模板组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateComponent {
|
||||
pub id: Option<i64>,
|
||||
#[serde(rename = "repoId")]
|
||||
pub repo_id: i64,
|
||||
#[serde(rename = "componentType")]
|
||||
pub component_type: ComponentType,
|
||||
pub category: Option<String>,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "contentHash")]
|
||||
pub content_hash: Option<String>,
|
||||
/// 是否已安装(前端展示用,需要在查询时填充)
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
/// 组件详情(含完整内容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComponentDetail {
|
||||
#[serde(flatten)]
|
||||
pub component: TemplateComponent,
|
||||
/// 完整文件内容
|
||||
pub content: String,
|
||||
/// 仓库所有者
|
||||
#[serde(rename = "repoOwner")]
|
||||
pub repo_owner: String,
|
||||
/// 仓库名称
|
||||
#[serde(rename = "repoName")]
|
||||
pub repo_name: String,
|
||||
/// 仓库分支
|
||||
#[serde(rename = "repoBranch")]
|
||||
pub repo_branch: String,
|
||||
/// GitHub README URL
|
||||
#[serde(rename = "readmeUrl")]
|
||||
pub readme_url: String,
|
||||
}
|
||||
|
||||
/// 组件元数据(从文件 front matter 解析)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ComponentMetadata {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// Agent 专用 - 工具列表
|
||||
pub tools: Option<String>,
|
||||
/// Agent 专用 - 模型名称
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// 分页结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PaginatedResult<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: u32,
|
||||
#[serde(rename = "pageSize")]
|
||||
pub page_size: u32,
|
||||
}
|
||||
|
||||
/// 批量安装结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchInstallResult {
|
||||
pub success: Vec<i64>,
|
||||
pub failed: Vec<(i64, String)>,
|
||||
}
|
||||
|
||||
/// 已安装组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstalledComponent {
|
||||
pub id: Option<i64>,
|
||||
#[serde(rename = "componentId")]
|
||||
pub component_id: Option<i64>,
|
||||
#[serde(rename = "componentType")]
|
||||
pub component_type: ComponentType,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
#[serde(rename = "appType")]
|
||||
pub app_type: String,
|
||||
#[serde(rename = "installedAt")]
|
||||
pub installed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 市场组合项(plugin 中的单个组件)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarketplaceBundleItem {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
#[serde(rename = "componentType")]
|
||||
pub component_type: String,
|
||||
}
|
||||
|
||||
/// 市场组合
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarketplaceBundle {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub components: Vec<MarketplaceBundleItem>,
|
||||
}
|
||||
|
||||
/// Template 服务
|
||||
pub struct TemplateService {
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
impl TemplateService {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
http_client: Client::builder()
|
||||
.user_agent("cc-switch")
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("创建 HTTP 客户端失败")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 HTTP 客户端
|
||||
pub fn client(&self) -> &Client {
|
||||
&self.http_client
|
||||
}
|
||||
|
||||
/// 获取应用配置目录
|
||||
pub fn get_app_config_dir(app_type: &str) -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
||||
|
||||
let dir = match app_type.to_lowercase().as_str() {
|
||||
"claude" => {
|
||||
// 检查是否有自定义 Claude 配置目录
|
||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||||
custom
|
||||
} else {
|
||||
home.join(".claude")
|
||||
}
|
||||
}
|
||||
"codex" => {
|
||||
// 检查是否有自定义 Codex 配置目录
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
custom
|
||||
} else {
|
||||
home.join(".codex")
|
||||
}
|
||||
}
|
||||
"gemini" => {
|
||||
// 检查是否有自定义 Gemini 配置目录
|
||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||
custom
|
||||
} else {
|
||||
home.join(".gemini")
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!("不支持的应用类型: {app_type}"),
|
||||
};
|
||||
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 从 components.json 获取市场组合
|
||||
pub async fn fetch_marketplace_bundles(
|
||||
&self,
|
||||
conn: &rusqlite::Connection,
|
||||
) -> Result<Vec<MarketplaceBundle>> {
|
||||
// 获取启用的仓库
|
||||
let repos = self.list_enabled_repos(conn)?;
|
||||
if repos.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut bundles = Vec::new();
|
||||
|
||||
for repo in repos {
|
||||
// 尝试多个可能的路径
|
||||
let urls = [
|
||||
format!(
|
||||
"https://raw.githubusercontent.com/{}/{}/{}/components.json",
|
||||
repo.owner, repo.name, repo.branch
|
||||
),
|
||||
format!(
|
||||
"https://raw.githubusercontent.com/{}/{}/{}/docs/components.json",
|
||||
repo.owner, repo.name, repo.branch
|
||||
),
|
||||
];
|
||||
|
||||
for url in urls {
|
||||
match self.http_client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
// 解析 marketplace.plugins(完整插件包)
|
||||
if let Some(marketplace) = json.get("marketplace") {
|
||||
if let Some(plugins) = marketplace.get("plugins") {
|
||||
if let Some(arr) = plugins.as_array() {
|
||||
for plugin in arr {
|
||||
let name = plugin
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let description = plugin
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// 提取各类型组件路径
|
||||
let mut components = Vec::new();
|
||||
let component_types = [
|
||||
"agents", "commands", "mcps", "settings", "hooks",
|
||||
"skills",
|
||||
];
|
||||
|
||||
for comp_type in component_types {
|
||||
if let Some(paths) =
|
||||
plugin.get(comp_type).and_then(|v| v.as_array())
|
||||
{
|
||||
// 单数形式的类型名
|
||||
let singular_type = match comp_type {
|
||||
"agents" => "agent",
|
||||
"commands" => "command",
|
||||
"mcps" => "mcp",
|
||||
"settings" => "setting",
|
||||
"hooks" => "hook",
|
||||
"skills" => "skill",
|
||||
_ => comp_type,
|
||||
};
|
||||
|
||||
for path_val in paths {
|
||||
if let Some(path) = path_val.as_str() {
|
||||
// 从路径提取组件名(文件名不含扩展名)
|
||||
let comp_name =
|
||||
std::path::Path::new(path)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
components.push(
|
||||
MarketplaceBundleItem {
|
||||
name: comp_name,
|
||||
path: path.to_string(),
|
||||
component_type: singular_type
|
||||
.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !components.is_empty() {
|
||||
bundles.push(MarketplaceBundle {
|
||||
id: format!("{}-plugin-{}", repo.name, name),
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
category: "plugin".to_string(),
|
||||
components,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // 成功获取后跳出 URL 循环
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(bundles)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TemplateService {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("创建 TemplateService 失败")
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use super::{TemplateRepo, TemplateService};
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TemplateService {
|
||||
/// 列出所有模板仓库
|
||||
pub fn list_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.context("准备查询模板仓库语句失败")?;
|
||||
|
||||
let repos = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get::<_, i64>(4)? != 0,
|
||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
||||
})
|
||||
})
|
||||
.context("查询模板仓库失败")?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("收集模板仓库结果失败")?;
|
||||
|
||||
Ok(repos)
|
||||
}
|
||||
|
||||
/// 添加模板仓库
|
||||
pub fn add_repo(&self, conn: &Connection, repo: TemplateRepo) -> Result<i64> {
|
||||
// 检查是否已存在
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM template_repos WHERE owner = ?1 AND name = ?2",
|
||||
params![&repo.owner, &repo.name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(id) = existing {
|
||||
// 更新已存在的仓库
|
||||
conn.execute(
|
||||
"UPDATE template_repos
|
||||
SET branch = ?1, enabled = ?2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?3",
|
||||
params![&repo.branch, repo.enabled as i64, id],
|
||||
)
|
||||
.context("更新模板仓库失败")?;
|
||||
Ok(id)
|
||||
} else {
|
||||
// 插入新仓库
|
||||
conn.execute(
|
||||
"INSERT INTO template_repos (owner, name, branch, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![&repo.owner, &repo.name, &repo.branch, repo.enabled as i64],
|
||||
)
|
||||
.context("插入模板仓库失败")?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除模板仓库
|
||||
pub fn remove_repo(&self, conn: &Connection, id: i64) -> Result<()> {
|
||||
let rows = conn
|
||||
.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
||||
.context("删除模板仓库失败")?;
|
||||
|
||||
if rows == 0 {
|
||||
anyhow::bail!("模板仓库不存在: id={id}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 切换仓库启用状态
|
||||
pub fn toggle_repo_enabled(&self, conn: &Connection, id: i64) -> Result<bool> {
|
||||
// 获取当前状态
|
||||
let enabled: i64 = conn
|
||||
.query_row(
|
||||
"SELECT enabled FROM template_repos WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("查询仓库状态失败")?;
|
||||
|
||||
let new_enabled = enabled == 0;
|
||||
|
||||
// 更新状态
|
||||
conn.execute(
|
||||
"UPDATE template_repos
|
||||
SET enabled = ?1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?2",
|
||||
params![new_enabled as i64, id],
|
||||
)
|
||||
.context("更新仓库状态失败")?;
|
||||
|
||||
Ok(new_enabled)
|
||||
}
|
||||
|
||||
/// 获取单个仓库
|
||||
pub fn get_repo(&self, conn: &Connection, id: i64) -> Result<TemplateRepo> {
|
||||
conn.query_row(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get::<_, i64>(4)? != 0,
|
||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
||||
})
|
||||
},
|
||||
)
|
||||
.context(format!("查询模板仓库失败: id={id}"))
|
||||
}
|
||||
|
||||
/// 获取启用的仓库列表
|
||||
pub fn list_enabled_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
WHERE enabled = 1
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.context("准备查询启用仓库语句失败")?;
|
||||
|
||||
let repos = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get::<_, i64>(4)? != 0,
|
||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
||||
})
|
||||
})
|
||||
.context("查询启用仓库失败")?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("收集启用仓库结果失败")?;
|
||||
|
||||
Ok(repos)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner, name)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_list_repos() {
|
||||
let conn = setup_db();
|
||||
let service = TemplateService::new().unwrap();
|
||||
|
||||
// 添加仓库
|
||||
let repo = TemplateRepo::new(
|
||||
"yovinchen".to_string(),
|
||||
"claude-code-templates".to_string(),
|
||||
"main".to_string(),
|
||||
);
|
||||
let id = service.add_repo(&conn, repo).unwrap();
|
||||
assert!(id > 0);
|
||||
|
||||
// 列出仓库
|
||||
let repos = service.list_repos(&conn).unwrap();
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].owner, "yovinchen");
|
||||
assert_eq!(repos[0].name, "claude-code-templates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_repo_enabled() {
|
||||
let conn = setup_db();
|
||||
let service = TemplateService::new().unwrap();
|
||||
|
||||
// 添加仓库
|
||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
||||
let id = service.add_repo(&conn, repo).unwrap();
|
||||
|
||||
// 切换状态
|
||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
||||
assert!(!enabled);
|
||||
|
||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
||||
assert!(enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_repo() {
|
||||
let conn = setup_db();
|
||||
let service = TemplateService::new().unwrap();
|
||||
|
||||
// 添加仓库
|
||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
||||
let id = service.add_repo(&conn, repo).unwrap();
|
||||
|
||||
// 删除仓库
|
||||
service.remove_repo(&conn, id).unwrap();
|
||||
|
||||
// 验证已删除
|
||||
let repos = service.list_repos(&conn).unwrap();
|
||||
assert_eq!(repos.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -802,25 +802,25 @@ pub(crate) fn find_model_pricing_row(
|
||||
conn: &Connection,
|
||||
model_id: &str,
|
||||
) -> Result<Option<(String, String, String, String)>, AppError> {
|
||||
// 清洗模型名称:去前缀(/)、去后缀(:)、@ 替换为 -
|
||||
// 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low
|
||||
let cleaned = model_id
|
||||
// 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905
|
||||
let without_prefix = model_id
|
||||
.rsplit_once('/')
|
||||
.map_or(model_id, |(_, r)| r)
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(model_id);
|
||||
let cleaned = without_prefix
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or(model_id)
|
||||
.trim()
|
||||
.replace('@', "-");
|
||||
.map(str::trim)
|
||||
.unwrap_or(without_prefix);
|
||||
|
||||
// 精确匹配清洗后的名称
|
||||
// 2) 精确匹配清洗后的名称
|
||||
let exact = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing
|
||||
WHERE model_id = ?1",
|
||||
[&cleaned],
|
||||
[cleaned],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
@@ -952,13 +952,6 @@ mod tests {
|
||||
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
|
||||
);
|
||||
|
||||
// 清洗:@ 替换为 -(seed_model_pricing 已预置 gpt-5.2-codex-low)
|
||||
let result = find_model_pricing_row(&conn, "gpt-5.2-codex@low")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low"
|
||||
);
|
||||
|
||||
// 测试不存在的模型
|
||||
let result = find_model_pricing_row(&conn, "unknown-model-123")?;
|
||||
assert!(result.is_none(), "不应该匹配不存在的模型");
|
||||
|
||||
+10
-23
@@ -92,9 +92,12 @@ impl Default for AppSettings {
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
fn settings_path() -> Option<PathBuf> {
|
||||
fn settings_path() -> PathBuf {
|
||||
// settings.json 保留用于旧版本迁移和无数据库场景
|
||||
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".cc-switch")
|
||||
.join("settings.json")
|
||||
}
|
||||
|
||||
fn normalize_paths(&mut self) {
|
||||
@@ -128,9 +131,7 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
fn load_from_file() -> Self {
|
||||
let Some(path) = Self::settings_path() else {
|
||||
return Self::default();
|
||||
};
|
||||
let path = Self::settings_path();
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
match serde_json::from_str::<AppSettings>(&content) {
|
||||
Ok(mut settings) => {
|
||||
@@ -155,9 +156,7 @@ impl AppSettings {
|
||||
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
|
||||
let mut normalized = settings.clone();
|
||||
normalized.normalize_paths();
|
||||
let Some(path) = AppSettings::settings_path() else {
|
||||
return Err(AppError::Config("无法获取用户主目录".to_string()));
|
||||
};
|
||||
let path = AppSettings::settings_path();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
@@ -194,23 +193,14 @@ fn resolve_override_path(raw: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn get_settings() -> AppSettings {
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.clone()
|
||||
settings_store().read().expect("读取设置锁失败").clone()
|
||||
}
|
||||
|
||||
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
new_settings.normalize_paths();
|
||||
save_settings_file(&new_settings)?;
|
||||
|
||||
let mut guard = settings_store().write().unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
});
|
||||
let mut guard = settings_store().write().expect("写入设置锁失败");
|
||||
*guard = new_settings;
|
||||
Ok(())
|
||||
}
|
||||
@@ -219,10 +209,7 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
/// 用于导入配置等场景,确保内存缓存与文件同步
|
||||
pub fn reload_settings() -> Result<(), AppError> {
|
||||
let fresh_settings = AppSettings::load_from_file();
|
||||
let mut guard = settings_store().write().unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
});
|
||||
let mut guard = settings_store().write().expect("写入设置锁失败");
|
||||
*guard = fresh_settings;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use reqwest::Client;
|
||||
use rquickjs::{Context, Function, Runtime};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use url::{Host, Url};
|
||||
|
||||
use crate::error::AppError;
|
||||
@@ -213,10 +215,18 @@ struct RequestConfig {
|
||||
|
||||
/// 发送 HTTP 请求
|
||||
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
|
||||
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
|
||||
// 约束超时范围,防止异常配置导致长时间阻塞
|
||||
let timeout = timeout_secs.clamp(2, 30);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.client_create_failed",
|
||||
format!("创建客户端失败: {e}"),
|
||||
format!("Failed to create client: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 严格校验 HTTP 方法,非法值不回退为 GET
|
||||
let method: reqwest::Method = config.method.parse().map_err(|_| {
|
||||
@@ -227,9 +237,7 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut req = client
|
||||
.request(method.clone(), &config.url)
|
||||
.timeout(request_timeout);
|
||||
let mut req = client.request(method.clone(), &config.url);
|
||||
|
||||
// 添加请求头
|
||||
for (k, v) in &config.headers {
|
||||
@@ -856,7 +864,9 @@ mod tests {
|
||||
} else {
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"应该不匹配的URL被允许: base_url={base_url}, request_url={request_url}"
|
||||
"应该不匹配的URL被允许: base_url={}, request_url={}",
|
||||
base_url,
|
||||
request_url
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.9.1",
|
||||
"version": "3.9.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "CC Switch",
|
||||
"titleBarStyle": "Visible",
|
||||
"minWidth": 900,
|
||||
"minHeight": 600
|
||||
|
||||
+5
-48
@@ -13,7 +13,6 @@ import {
|
||||
Wrench,
|
||||
Server,
|
||||
RefreshCw,
|
||||
Package,
|
||||
Search,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
@@ -32,7 +31,6 @@ import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isWindows, isLinux } from "@/lib/platform";
|
||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||
import { ProviderList } from "@/components/providers/ProviderList";
|
||||
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
||||
@@ -49,7 +47,6 @@ import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||
import { TemplatesPage } from "@/components/templates/TemplatesPage";
|
||||
import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -60,12 +57,10 @@ type View =
|
||||
| "skills"
|
||||
| "skillsDiscovery"
|
||||
| "mcp"
|
||||
| "templates"
|
||||
| "agents"
|
||||
| "universal";
|
||||
|
||||
// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar
|
||||
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
||||
const DRAG_BAR_HEIGHT = 28; // px
|
||||
const HEADER_HEIGHT = 64; // px
|
||||
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||
|
||||
@@ -385,26 +380,6 @@ function App() {
|
||||
await addProvider(duplicatedProvider);
|
||||
};
|
||||
|
||||
// 打开提供商终端
|
||||
const handleOpenTerminal = async (provider: Provider) => {
|
||||
try {
|
||||
await providersApi.openTerminal(provider.id, activeApp);
|
||||
toast.success(
|
||||
t("provider.terminalOpened", {
|
||||
defaultValue: "终端已打开",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[App] Failed to open terminal", error);
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
toast.error(
|
||||
t("provider.terminalOpenFailed", {
|
||||
defaultValue: "打开终端失败",
|
||||
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导入配置成功后刷新
|
||||
const handleImportSuccess = async () => {
|
||||
try {
|
||||
@@ -475,8 +450,6 @@ function App() {
|
||||
<UniversalProviderPanel />
|
||||
</div>
|
||||
);
|
||||
case "templates":
|
||||
return <TemplatesPage activeApp={activeApp} />;
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
@@ -507,9 +480,6 @@ function App() {
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
onOpenTerminal={
|
||||
activeApp === "claude" ? handleOpenTerminal : undefined
|
||||
}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
/>
|
||||
</motion.div>
|
||||
@@ -617,7 +587,6 @@ function App() {
|
||||
{currentView === "skillsDiscovery" && t("skills.title")}
|
||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||
{currentView === "agents" && t("agents.title")}
|
||||
{currentView === "templates" && t("templates.title")}
|
||||
{currentView === "universal" &&
|
||||
t("universalProvider.title", {
|
||||
defaultValue: "统一供应商",
|
||||
@@ -653,12 +622,10 @@ function App() {
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<UpdateBadge
|
||||
onClick={() => {
|
||||
setSettingsDefaultTab("about");
|
||||
setCurrentView("settings");
|
||||
}}
|
||||
/>
|
||||
<UpdateBadge onClick={() => {
|
||||
setSettingsDefaultTab("about");
|
||||
setCurrentView("settings");
|
||||
}} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -796,15 +763,6 @@ function App() {
|
||||
>
|
||||
<Server className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("templates")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("templates.title")}
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -846,7 +804,6 @@ function App() {
|
||||
|
||||
{effectiveUsageProvider && (
|
||||
<UsageScriptModal
|
||||
key={effectiveUsageProvider.id}
|
||||
provider={effectiveUsageProvider}
|
||||
appId={activeApp}
|
||||
isOpen={Boolean(usageProvider)}
|
||||
|
||||
@@ -389,27 +389,12 @@ export function DeepLinkImportDialog() {
|
||||
</div>
|
||||
|
||||
{/* API Endpoint */}
|
||||
<div className="grid grid-cols-3 items-start gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground pt-0.5">
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.endpoint")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all space-y-1">
|
||||
{request.endpoint?.split(",").map((ep, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={
|
||||
idx === 0 ? "font-medium" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{idx === 0 ? "🔹 " : "└ "}
|
||||
{ep.trim()}
|
||||
{idx === 0 && request.endpoint?.includes(",") && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({t("deeplink.primaryEndpoint")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="col-span-2 text-sm break-all">
|
||||
{request.endpoint}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createPortal } from "react-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isWindows, isLinux } from "@/lib/platform";
|
||||
|
||||
interface FullScreenPanelProps {
|
||||
isOpen: boolean;
|
||||
@@ -13,7 +12,7 @@ interface FullScreenPanelProps {
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx
|
||||
const DRAG_BAR_HEIGHT = 28; // px - match App.tsx
|
||||
const HEADER_HEIGHT = 64; // px - match App.tsx
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
Terminal,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -24,7 +23,6 @@ interface ProviderActionsProps {
|
||||
onTest?: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onDelete: () => void;
|
||||
onOpenTerminal?: () => void;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled?: boolean;
|
||||
isInFailoverQueue?: boolean;
|
||||
@@ -41,7 +39,6 @@ export function ProviderActions({
|
||||
onTest,
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
onOpenTerminal,
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled = false,
|
||||
isInFailoverQueue = false,
|
||||
@@ -174,21 +171,6 @@ export function ProviderActions({
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{onOpenTerminal && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onOpenTerminal}
|
||||
title={t("provider.openTerminal", "打开终端")}
|
||||
className={cn(
|
||||
iconButtonClass,
|
||||
"hover:text-emerald-600 dark:hover:text-emerald-400",
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, useEffect, useRef } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
@@ -33,7 +33,6 @@ interface ProviderCardProps {
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onTest?: (provider: Provider) => void;
|
||||
onOpenTerminal?: (provider: Provider) => void;
|
||||
isTesting?: boolean;
|
||||
isProxyRunning: boolean;
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||
@@ -92,7 +91,6 @@ export function ProviderCard({
|
||||
onOpenWebsite,
|
||||
onDuplicate,
|
||||
onTest,
|
||||
onOpenTerminal,
|
||||
isTesting,
|
||||
isProxyRunning,
|
||||
isProxyTakeover = false,
|
||||
@@ -149,10 +147,6 @@ export function ProviderCard({
|
||||
// 多套餐默认展开
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 操作按钮容器 ref,用于动态计算宽度
|
||||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState(0);
|
||||
|
||||
// 当检测到多套餐时自动展开
|
||||
useEffect(() => {
|
||||
if (hasMultiplePlans) {
|
||||
@@ -160,20 +154,6 @@ export function ProviderCard({
|
||||
}
|
||||
}, [hasMultiplePlans]);
|
||||
|
||||
// 动态获取操作按钮宽度
|
||||
useEffect(() => {
|
||||
if (actionsRef.current) {
|
||||
const updateWidth = () => {
|
||||
const width = actionsRef.current?.offsetWidth || 0;
|
||||
setActionsWidth(width);
|
||||
};
|
||||
updateWidth();
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => window.removeEventListener("resize", updateWidth);
|
||||
}
|
||||
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
|
||||
|
||||
const handleOpenWebsite = () => {
|
||||
if (!isClickableUrl) {
|
||||
return;
|
||||
@@ -299,17 +279,10 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative flex items-center ml-auto min-w-0 gap-3"
|
||||
style={
|
||||
{
|
||||
"--actions-width": `${actionsWidth || 320}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="relative flex items-center ml-auto min-w-0">
|
||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
||||
<div className="ml-auto">
|
||||
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
|
||||
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
||||
{hasMultiplePlans ? (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
@@ -354,11 +327,8 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
|
||||
<div
|
||||
ref={actionsRef}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
||||
>
|
||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
||||
<ProviderActions
|
||||
isCurrent={isCurrent}
|
||||
isTesting={isTesting}
|
||||
@@ -369,9 +339,6 @@ export function ProviderCard({
|
||||
onTest={onTest ? () => onTest(provider) : undefined}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
onOpenTerminal={
|
||||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||
}
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
|
||||
@@ -41,7 +41,6 @@ interface ProviderListProps {
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onOpenTerminal?: (provider: Provider) => void;
|
||||
onCreate?: () => void;
|
||||
isLoading?: boolean;
|
||||
isProxyRunning?: boolean; // 代理服务运行状态
|
||||
@@ -59,7 +58,6 @@ export function ProviderList({
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onOpenTerminal,
|
||||
onCreate,
|
||||
isLoading = false,
|
||||
isProxyRunning = false,
|
||||
@@ -205,7 +203,6 @@ export function ProviderList({
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onTest={handleTest}
|
||||
isTesting={isChecking(provider.id)}
|
||||
isProxyRunning={isProxyRunning}
|
||||
@@ -314,7 +311,6 @@ interface SortableProviderCardProps {
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onOpenTerminal?: (provider: Provider) => void;
|
||||
onTest: (provider: Provider) => void;
|
||||
isTesting: boolean;
|
||||
isProxyRunning: boolean;
|
||||
@@ -337,7 +333,6 @@ function SortableProviderCard({
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onOpenTerminal,
|
||||
onTest,
|
||||
isTesting,
|
||||
isProxyRunning,
|
||||
@@ -376,7 +371,6 @@ function SortableProviderCard({
|
||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||
}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
isProxyRunning={isProxyRunning}
|
||||
|
||||
@@ -36,8 +36,6 @@ interface ClaudeFormFieldsProps {
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Model Selector
|
||||
shouldShowModelSelector: boolean;
|
||||
@@ -85,8 +83,6 @@ export function ClaudeFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
shouldShowModelSelector,
|
||||
claudeModel,
|
||||
reasoningModel,
|
||||
@@ -174,8 +170,6 @@ export function ClaudeFormFields({
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
autoSelect={autoSelect}
|
||||
onAutoSelectChange={onAutoSelectChange}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,8 +25,6 @@ interface CodexFormFieldsProps {
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Model Name
|
||||
shouldShowModelField?: boolean;
|
||||
@@ -52,8 +50,6 @@ export function CodexFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
shouldShowModelField = true,
|
||||
modelName = "",
|
||||
onModelNameChange,
|
||||
@@ -134,8 +130,6 @@ export function CodexFormFields({
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
autoSelect={autoSelect}
|
||||
onAutoSelectChange={onAutoSelectChange}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -30,8 +30,6 @@ interface EndpointSpeedTestProps {
|
||||
initialEndpoints: EndpointCandidate[];
|
||||
visible?: boolean;
|
||||
onClose: () => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
||||
// 编辑模式:不使用此回调,端点直接保存到后端
|
||||
onCustomEndpointsChange?: (urls: string[]) => void;
|
||||
@@ -87,8 +85,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
initialEndpoints,
|
||||
visible = true,
|
||||
onClose,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
onCustomEndpointsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -97,6 +93,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
);
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -491,9 +488,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSelect}
|
||||
onChange={(event) => {
|
||||
onAutoSelectChange(event.target.checked);
|
||||
}}
|
||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
{t("endpointTest.autoSelect")}
|
||||
|
||||
@@ -29,8 +29,6 @@ interface GeminiFormFieldsProps {
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Model
|
||||
shouldShowModelField: boolean;
|
||||
@@ -57,8 +55,6 @@ export function GeminiFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
shouldShowModelField,
|
||||
model,
|
||||
onModelChange,
|
||||
@@ -146,8 +142,6 @@ export function GeminiFormFields({
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
autoSelect={autoSelect}
|
||||
onAutoSelectChange={onAutoSelectChange}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -124,9 +124,6 @@ export function ProviderForm({
|
||||
return [];
|
||||
},
|
||||
);
|
||||
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
|
||||
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||
);
|
||||
|
||||
// 使用 category hook
|
||||
const { category } = useProviderCategory({
|
||||
@@ -144,7 +141,6 @@ export function ProviderForm({
|
||||
if (!initialData) {
|
||||
setDraftCustomEndpoints([]);
|
||||
}
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
}, [appId, initialData]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -240,7 +236,7 @@ export function ProviderForm({
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false; // OpenRouter now supports Claude Code compatible API, no need for transform
|
||||
return true;
|
||||
}, [isOpenRouterProvider, settingsConfigValue]);
|
||||
|
||||
const handleOpenRouterCompatChange = useCallback(
|
||||
@@ -651,13 +647,6 @@ export function ProviderForm({
|
||||
}
|
||||
}
|
||||
|
||||
const baseMeta: ProviderMeta | undefined =
|
||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||
payload.meta = {
|
||||
...(baseMeta ?? {}),
|
||||
endpointAutoSelect,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
@@ -867,8 +856,6 @@ export function ProviderForm({
|
||||
onCustomEndpointsChange={
|
||||
isEditMode ? undefined : setDraftCustomEndpoints
|
||||
}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
shouldShowModelSelector={category !== "official"}
|
||||
claudeModel={claudeModel}
|
||||
reasoningModel={reasoningModel}
|
||||
@@ -877,7 +864,7 @@ export function ProviderForm({
|
||||
defaultOpusModel={defaultOpusModel}
|
||||
onModelChange={handleModelChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
showOpenRouterCompatToggle={false}
|
||||
showOpenRouterCompatToggle={isOpenRouterProvider}
|
||||
openRouterCompatEnabled={openRouterCompatEnabled}
|
||||
onOpenRouterCompatChange={handleOpenRouterCompatChange}
|
||||
/>
|
||||
@@ -902,8 +889,6 @@ export function ProviderForm({
|
||||
onCustomEndpointsChange={
|
||||
isEditMode ? undefined : setDraftCustomEndpoints
|
||||
}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
shouldShowModelField={category !== "official"}
|
||||
modelName={codexModelName}
|
||||
onModelNameChange={handleCodexModelNameChange}
|
||||
@@ -932,8 +917,6 @@ export function ProviderForm({
|
||||
isEndpointModalOpen={isEndpointModalOpen}
|
||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
shouldShowModelField={true}
|
||||
model={geminiModel}
|
||||
onModelChange={handleGeminiModelChange}
|
||||
|
||||
@@ -43,7 +43,11 @@ export function useApiKeyState({
|
||||
return;
|
||||
}
|
||||
|
||||
// 从配置中提取 API Key(如果不存在则返回空字符串)
|
||||
// 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key)
|
||||
if (!hasApiKeyField(initialConfig, appType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extracted = getApiKeyFromConfig(initialConfig, appType);
|
||||
if (extracted !== apiKey) {
|
||||
setApiKey(extracted);
|
||||
|
||||
@@ -41,9 +41,8 @@ export function useBaseUrlState({
|
||||
try {
|
||||
const config = JSON.parse(settingsConfig || "{}");
|
||||
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
|
||||
const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
|
||||
if (nextUrl !== baseUrl) {
|
||||
setBaseUrl(nextUrl);
|
||||
if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) {
|
||||
setBaseUrl(envUrl.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@@ -21,157 +21,52 @@ export function AutoFailoverConfigPanel({
|
||||
const { data: config, isLoading, error } = useAppProxyConfig(appType);
|
||||
const updateConfig = useUpdateAppProxyConfig();
|
||||
|
||||
// 使用字符串状态以支持完全清空数字输入框
|
||||
const [formData, setFormData] = useState({
|
||||
autoFailoverEnabled: false,
|
||||
maxRetries: "3",
|
||||
streamingFirstByteTimeout: "30",
|
||||
streamingIdleTimeout: "60",
|
||||
nonStreamingTimeout: "300",
|
||||
circuitFailureThreshold: "5",
|
||||
circuitSuccessThreshold: "2",
|
||||
circuitTimeoutSeconds: "60",
|
||||
circuitErrorRateThreshold: "50", // 存储百分比值
|
||||
circuitMinRequests: "10",
|
||||
maxRetries: 3,
|
||||
streamingFirstByteTimeout: 30,
|
||||
streamingIdleTimeout: 60,
|
||||
nonStreamingTimeout: 300,
|
||||
circuitFailureThreshold: 5,
|
||||
circuitSuccessThreshold: 2,
|
||||
circuitTimeoutSeconds: 60,
|
||||
circuitErrorRateThreshold: 0.5,
|
||||
circuitMinRequests: 10,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||
maxRetries: String(config.maxRetries),
|
||||
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
|
||||
streamingIdleTimeout: String(config.streamingIdleTimeout),
|
||||
nonStreamingTimeout: String(config.nonStreamingTimeout),
|
||||
circuitFailureThreshold: String(config.circuitFailureThreshold),
|
||||
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
|
||||
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
|
||||
circuitErrorRateThreshold: String(
|
||||
Math.round(config.circuitErrorRateThreshold * 100),
|
||||
),
|
||||
circuitMinRequests: String(config.circuitMinRequests),
|
||||
maxRetries: config.maxRetries,
|
||||
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
|
||||
streamingIdleTimeout: config.streamingIdleTimeout,
|
||||
nonStreamingTimeout: config.nonStreamingTimeout,
|
||||
circuitFailureThreshold: config.circuitFailureThreshold,
|
||||
circuitSuccessThreshold: config.circuitSuccessThreshold,
|
||||
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
|
||||
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
|
||||
circuitMinRequests: config.circuitMinRequests,
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
// 解析数字,返回 NaN 表示无效输入
|
||||
const parseNum = (val: string) => {
|
||||
const trimmed = val.trim();
|
||||
// 必须是纯数字
|
||||
if (!/^-?\d+$/.test(trimmed)) return NaN;
|
||||
return parseInt(trimmed);
|
||||
};
|
||||
|
||||
// 定义各字段的有效范围
|
||||
const ranges = {
|
||||
maxRetries: { min: 0, max: 10 },
|
||||
streamingFirstByteTimeout: { min: 0, max: 180 },
|
||||
streamingIdleTimeout: { min: 0, max: 600 },
|
||||
nonStreamingTimeout: { min: 0, max: 1800 },
|
||||
circuitFailureThreshold: { min: 1, max: 20 },
|
||||
circuitSuccessThreshold: { min: 1, max: 10 },
|
||||
circuitTimeoutSeconds: { min: 0, max: 300 },
|
||||
circuitErrorRateThreshold: { min: 0, max: 100 },
|
||||
circuitMinRequests: { min: 5, max: 100 },
|
||||
};
|
||||
|
||||
// 解析原始值
|
||||
const raw = {
|
||||
maxRetries: parseNum(formData.maxRetries),
|
||||
streamingFirstByteTimeout: parseNum(formData.streamingFirstByteTimeout),
|
||||
streamingIdleTimeout: parseNum(formData.streamingIdleTimeout),
|
||||
nonStreamingTimeout: parseNum(formData.nonStreamingTimeout),
|
||||
circuitFailureThreshold: parseNum(formData.circuitFailureThreshold),
|
||||
circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold),
|
||||
circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds),
|
||||
circuitErrorRateThreshold: parseNum(formData.circuitErrorRateThreshold),
|
||||
circuitMinRequests: parseNum(formData.circuitMinRequests),
|
||||
};
|
||||
|
||||
// 校验是否超出范围(NaN 也视为无效)
|
||||
const errors: string[] = [];
|
||||
const checkRange = (
|
||||
value: number,
|
||||
range: { min: number; max: number },
|
||||
label: string,
|
||||
) => {
|
||||
if (isNaN(value) || value < range.min || value > range.max) {
|
||||
errors.push(`${label}: ${range.min}-${range.max}`);
|
||||
}
|
||||
};
|
||||
|
||||
checkRange(
|
||||
raw.maxRetries,
|
||||
ranges.maxRetries,
|
||||
t("proxy.autoFailover.maxRetries", "最大重试次数"),
|
||||
);
|
||||
checkRange(
|
||||
raw.streamingFirstByteTimeout,
|
||||
ranges.streamingFirstByteTimeout,
|
||||
t("proxy.autoFailover.streamingFirstByte", "流式首字节超时"),
|
||||
);
|
||||
checkRange(
|
||||
raw.streamingIdleTimeout,
|
||||
ranges.streamingIdleTimeout,
|
||||
t("proxy.autoFailover.streamingIdle", "流式静默超时"),
|
||||
);
|
||||
checkRange(
|
||||
raw.nonStreamingTimeout,
|
||||
ranges.nonStreamingTimeout,
|
||||
t("proxy.autoFailover.nonStreaming", "非流式超时"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitFailureThreshold,
|
||||
ranges.circuitFailureThreshold,
|
||||
t("proxy.autoFailover.failureThreshold", "失败阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitSuccessThreshold,
|
||||
ranges.circuitSuccessThreshold,
|
||||
t("proxy.autoFailover.successThreshold", "恢复成功阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitTimeoutSeconds,
|
||||
ranges.circuitTimeoutSeconds,
|
||||
t("proxy.autoFailover.timeout", "恢复等待时间"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitErrorRateThreshold,
|
||||
ranges.circuitErrorRateThreshold,
|
||||
t("proxy.autoFailover.errorRate", "错误率阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.circuitMinRequests,
|
||||
ranges.circuitMinRequests,
|
||||
t("proxy.autoFailover.minRequests", "最小请求数"),
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(
|
||||
t("proxy.autoFailover.validationFailed", {
|
||||
fields: errors.join("; "),
|
||||
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateConfig.mutateAsync({
|
||||
appType,
|
||||
enabled: config.enabled,
|
||||
autoFailoverEnabled: formData.autoFailoverEnabled,
|
||||
maxRetries: raw.maxRetries,
|
||||
streamingFirstByteTimeout: raw.streamingFirstByteTimeout,
|
||||
streamingIdleTimeout: raw.streamingIdleTimeout,
|
||||
nonStreamingTimeout: raw.nonStreamingTimeout,
|
||||
circuitFailureThreshold: raw.circuitFailureThreshold,
|
||||
circuitSuccessThreshold: raw.circuitSuccessThreshold,
|
||||
circuitTimeoutSeconds: raw.circuitTimeoutSeconds,
|
||||
circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100,
|
||||
circuitMinRequests: raw.circuitMinRequests,
|
||||
maxRetries: formData.maxRetries,
|
||||
streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
|
||||
streamingIdleTimeout: formData.streamingIdleTimeout,
|
||||
nonStreamingTimeout: formData.nonStreamingTimeout,
|
||||
circuitFailureThreshold: formData.circuitFailureThreshold,
|
||||
circuitSuccessThreshold: formData.circuitSuccessThreshold,
|
||||
circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
|
||||
circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
|
||||
circuitMinRequests: formData.circuitMinRequests,
|
||||
});
|
||||
toast.success(
|
||||
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
||||
@@ -188,17 +83,15 @@ export function AutoFailoverConfigPanel({
|
||||
if (config) {
|
||||
setFormData({
|
||||
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||
maxRetries: String(config.maxRetries),
|
||||
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
|
||||
streamingIdleTimeout: String(config.streamingIdleTimeout),
|
||||
nonStreamingTimeout: String(config.nonStreamingTimeout),
|
||||
circuitFailureThreshold: String(config.circuitFailureThreshold),
|
||||
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
|
||||
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
|
||||
circuitErrorRateThreshold: String(
|
||||
Math.round(config.circuitErrorRateThreshold * 100),
|
||||
),
|
||||
circuitMinRequests: String(config.circuitMinRequests),
|
||||
maxRetries: config.maxRetries,
|
||||
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
|
||||
streamingIdleTimeout: config.streamingIdleTimeout,
|
||||
nonStreamingTimeout: config.nonStreamingTimeout,
|
||||
circuitFailureThreshold: config.circuitFailureThreshold,
|
||||
circuitSuccessThreshold: config.circuitSuccessThreshold,
|
||||
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
|
||||
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
|
||||
circuitMinRequests: config.circuitMinRequests,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -249,9 +142,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="10"
|
||||
value={formData.maxRetries}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, maxRetries: e.target.value })
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
maxRetries: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -272,12 +169,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.circuitFailureThreshold}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitFailureThreshold: e.target.value,
|
||||
})
|
||||
}
|
||||
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -310,12 +208,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="180"
|
||||
value={formData.streamingFirstByteTimeout}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
streamingFirstByteTimeout: e.target.value,
|
||||
})
|
||||
}
|
||||
streamingFirstByteTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -336,12 +235,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="600"
|
||||
value={formData.streamingIdleTimeout}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
streamingIdleTimeout: e.target.value,
|
||||
})
|
||||
}
|
||||
streamingIdleTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -362,12 +262,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="1800"
|
||||
value={formData.nonStreamingTimeout}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
nonStreamingTimeout: e.target.value,
|
||||
})
|
||||
}
|
||||
nonStreamingTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -397,12 +298,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="1"
|
||||
max="10"
|
||||
value={formData.circuitSuccessThreshold}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitSuccessThreshold: e.target.value,
|
||||
})
|
||||
}
|
||||
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -420,15 +322,16 @@ export function AutoFailoverConfigPanel({
|
||||
<Input
|
||||
id={`timeoutSeconds-${appType}`}
|
||||
type="number"
|
||||
min="0"
|
||||
min="10"
|
||||
max="300"
|
||||
value={formData.circuitTimeoutSeconds}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitTimeoutSeconds: e.target.value,
|
||||
})
|
||||
}
|
||||
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -449,13 +352,14 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={formData.circuitErrorRateThreshold}
|
||||
onChange={(e) =>
|
||||
value={Math.round(formData.circuitErrorRateThreshold * 100)}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitErrorRateThreshold: e.target.value,
|
||||
})
|
||||
}
|
||||
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -476,12 +380,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="5"
|
||||
max="100"
|
||||
value={formData.circuitMinRequests}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitMinRequests: e.target.value,
|
||||
})
|
||||
}
|
||||
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -7,141 +7,42 @@ import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* 熔断器配置面板
|
||||
* 允许用户调整熔断器参数
|
||||
*/
|
||||
export function CircuitBreakerConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config, isLoading } = useCircuitBreakerConfig();
|
||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
||||
|
||||
// 使用字符串状态以支持完全清空输入框
|
||||
const [formData, setFormData] = useState({
|
||||
failureThreshold: "5",
|
||||
successThreshold: "2",
|
||||
timeoutSeconds: "60",
|
||||
errorRateThreshold: "50", // 存储百分比值
|
||||
minRequests: "10",
|
||||
failureThreshold: 5,
|
||||
successThreshold: 2,
|
||||
timeoutSeconds: 60,
|
||||
errorRateThreshold: 0.5,
|
||||
minRequests: 10,
|
||||
});
|
||||
|
||||
// 当配置加载完成时更新表单数据
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
failureThreshold: String(config.failureThreshold),
|
||||
successThreshold: String(config.successThreshold),
|
||||
timeoutSeconds: String(config.timeoutSeconds),
|
||||
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
||||
minRequests: String(config.minRequests),
|
||||
});
|
||||
setFormData(config);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 解析数字,返回 NaN 表示无效输入
|
||||
const parseNum = (val: string) => {
|
||||
const trimmed = val.trim();
|
||||
// 必须是纯数字
|
||||
if (!/^-?\d+$/.test(trimmed)) return NaN;
|
||||
return parseInt(trimmed);
|
||||
};
|
||||
|
||||
// 定义各字段的有效范围
|
||||
const ranges = {
|
||||
failureThreshold: { min: 1, max: 20 },
|
||||
successThreshold: { min: 1, max: 10 },
|
||||
timeoutSeconds: { min: 0, max: 300 },
|
||||
errorRateThreshold: { min: 0, max: 100 },
|
||||
minRequests: { min: 5, max: 100 },
|
||||
};
|
||||
|
||||
// 解析原始值
|
||||
const raw = {
|
||||
failureThreshold: parseNum(formData.failureThreshold),
|
||||
successThreshold: parseNum(formData.successThreshold),
|
||||
timeoutSeconds: parseNum(formData.timeoutSeconds),
|
||||
errorRateThreshold: parseNum(formData.errorRateThreshold),
|
||||
minRequests: parseNum(formData.minRequests),
|
||||
};
|
||||
|
||||
// 校验是否超出范围(NaN 也视为无效)
|
||||
const errors: string[] = [];
|
||||
const checkRange = (
|
||||
value: number,
|
||||
range: { min: number; max: number },
|
||||
label: string,
|
||||
) => {
|
||||
if (isNaN(value) || value < range.min || value > range.max) {
|
||||
errors.push(`${label}: ${range.min}-${range.max}`);
|
||||
}
|
||||
};
|
||||
|
||||
checkRange(
|
||||
raw.failureThreshold,
|
||||
ranges.failureThreshold,
|
||||
t("circuitBreaker.failureThreshold", "失败阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.successThreshold,
|
||||
ranges.successThreshold,
|
||||
t("circuitBreaker.successThreshold", "成功阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.timeoutSeconds,
|
||||
ranges.timeoutSeconds,
|
||||
t("circuitBreaker.timeoutSeconds", "超时时间"),
|
||||
);
|
||||
checkRange(
|
||||
raw.errorRateThreshold,
|
||||
ranges.errorRateThreshold,
|
||||
t("circuitBreaker.errorRateThreshold", "错误率阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.minRequests,
|
||||
ranges.minRequests,
|
||||
t("circuitBreaker.minRequests", "最小请求数"),
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(
|
||||
t("circuitBreaker.validationFailed", {
|
||||
fields: errors.join("; "),
|
||||
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateConfig.mutateAsync({
|
||||
failureThreshold: raw.failureThreshold,
|
||||
successThreshold: raw.successThreshold,
|
||||
timeoutSeconds: raw.timeoutSeconds,
|
||||
errorRateThreshold: raw.errorRateThreshold / 100,
|
||||
minRequests: raw.minRequests,
|
||||
});
|
||||
toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
|
||||
closeButton: true,
|
||||
});
|
||||
await updateConfig.mutateAsync(formData);
|
||||
toast.success("熔断器配置已保存", { closeButton: true });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
|
||||
);
|
||||
toast.error("保存失败: " + String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
failureThreshold: String(config.failureThreshold),
|
||||
successThreshold: String(config.successThreshold),
|
||||
timeoutSeconds: String(config.timeoutSeconds),
|
||||
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
||||
minRequests: String(config.minRequests),
|
||||
});
|
||||
setFormData(config);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -171,7 +72,10 @@ export function CircuitBreakerConfigPanel() {
|
||||
max="20"
|
||||
value={formData.failureThreshold}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, failureThreshold: e.target.value })
|
||||
setFormData({
|
||||
...formData,
|
||||
failureThreshold: parseInt(e.target.value) || 5,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -185,11 +89,14 @@ export function CircuitBreakerConfigPanel() {
|
||||
<Input
|
||||
id="timeoutSeconds"
|
||||
type="number"
|
||||
min="0"
|
||||
min="10"
|
||||
max="300"
|
||||
value={formData.timeoutSeconds}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, timeoutSeconds: e.target.value })
|
||||
setFormData({
|
||||
...formData,
|
||||
timeoutSeconds: parseInt(e.target.value) || 60,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -207,7 +114,10 @@ export function CircuitBreakerConfigPanel() {
|
||||
max="10"
|
||||
value={formData.successThreshold}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, successThreshold: e.target.value })
|
||||
setFormData({
|
||||
...formData,
|
||||
successThreshold: parseInt(e.target.value) || 2,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -224,9 +134,12 @@ export function CircuitBreakerConfigPanel() {
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={formData.errorRateThreshold}
|
||||
value={Math.round(formData.errorRateThreshold * 100)}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, errorRateThreshold: e.target.value })
|
||||
setFormData({
|
||||
...formData,
|
||||
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -244,7 +157,10 @@ export function CircuitBreakerConfigPanel() {
|
||||
max="100"
|
||||
value={formData.minRequests}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, minRequests: e.target.value })
|
||||
setFormData({
|
||||
...formData,
|
||||
minRequests: parseInt(e.target.value) || 10,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -38,15 +38,15 @@ export function ProxyPanel() {
|
||||
const { data: globalConfig } = useGlobalProxyConfig();
|
||||
const updateGlobalConfig = useUpdateGlobalProxyConfig();
|
||||
|
||||
// 监听地址/端口的本地状态(端口用字符串以支持完全清空)
|
||||
// 监听地址/端口的本地状态
|
||||
const [listenAddress, setListenAddress] = useState("127.0.0.1");
|
||||
const [listenPort, setListenPort] = useState("15721");
|
||||
const [listenPort, setListenPort] = useState(15721);
|
||||
|
||||
// 同步全局配置到本地状态
|
||||
useEffect(() => {
|
||||
if (globalConfig) {
|
||||
setListenAddress(globalConfig.listenAddress);
|
||||
setListenPort(String(globalConfig.listenPort));
|
||||
setListenPort(globalConfig.listenPort);
|
||||
}
|
||||
}, [globalConfig]);
|
||||
|
||||
@@ -102,52 +102,11 @@ export function ProxyPanel() {
|
||||
|
||||
const handleSaveBasicConfig = async () => {
|
||||
if (!globalConfig) return;
|
||||
|
||||
// 校验地址格式(简单的 IP 地址或 localhost 校验)
|
||||
const addressTrimmed = listenAddress.trim();
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
const isValidAddress =
|
||||
addressTrimmed === "localhost" ||
|
||||
addressTrimmed === "0.0.0.0" ||
|
||||
(ipv4Regex.test(addressTrimmed) &&
|
||||
addressTrimmed.split(".").every((n) => {
|
||||
const num = parseInt(n);
|
||||
return num >= 0 && num <= 255;
|
||||
}));
|
||||
if (!isValidAddress) {
|
||||
toast.error(
|
||||
t("proxy.settings.invalidAddress", {
|
||||
defaultValue:
|
||||
"地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 严格校验端口:必须是纯数字
|
||||
const portTrimmed = listenPort.trim();
|
||||
if (!/^\d+$/.test(portTrimmed)) {
|
||||
toast.error(
|
||||
t("proxy.settings.invalidPort", {
|
||||
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const port = parseInt(portTrimmed);
|
||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||
toast.error(
|
||||
t("proxy.settings.invalidPort", {
|
||||
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateGlobalConfig.mutateAsync({
|
||||
...globalConfig,
|
||||
listenAddress: addressTrimmed,
|
||||
listenPort: port,
|
||||
listenAddress,
|
||||
listenPort,
|
||||
});
|
||||
toast.success(
|
||||
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
|
||||
@@ -174,13 +133,6 @@ export function ProxyPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化地址用于 URL(IPv6 需要方括号)
|
||||
const formatAddressForUrl = (address: string, port: number): string => {
|
||||
const isIPv6 = address.includes(":");
|
||||
const host = isIPv6 ? `[${address}]` : address;
|
||||
return `http://${host}:${port}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-6">
|
||||
@@ -195,14 +147,14 @@ export function ProxyPanel() {
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
|
||||
{formatAddressForUrl(status.address, status.port)}
|
||||
http://{status.address}:{status.port}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
formatAddressForUrl(status.address, status.port),
|
||||
`http://${status.address}:${status.port}`,
|
||||
);
|
||||
toast.success(
|
||||
t("proxy.panel.addressCopied", {
|
||||
@@ -437,12 +389,9 @@ export function ProxyPanel() {
|
||||
id="listen-address"
|
||||
value={listenAddress}
|
||||
onChange={(e) => setListenAddress(e.target.value)}
|
||||
placeholder={t(
|
||||
"proxy.settings.fields.listenAddress.placeholder",
|
||||
{
|
||||
defaultValue: "127.0.0.1",
|
||||
},
|
||||
)}
|
||||
placeholder={t("proxy.settings.fields.listenAddress.placeholder", {
|
||||
defaultValue: "127.0.0.1",
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.settings.fields.listenAddress.description", {
|
||||
@@ -462,13 +411,12 @@ export function ProxyPanel() {
|
||||
id="listen-port"
|
||||
type="number"
|
||||
value={listenPort}
|
||||
onChange={(e) => setListenPort(e.target.value)}
|
||||
placeholder={t(
|
||||
"proxy.settings.fields.listenPort.placeholder",
|
||||
{
|
||||
defaultValue: "15721",
|
||||
},
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setListenPort(parseInt(e.target.value) || 15721)
|
||||
}
|
||||
placeholder={t("proxy.settings.fields.listenPort.placeholder", {
|
||||
defaultValue: "15721",
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.settings.fields.listenPort.description", {
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* 全局出站代理设置组件
|
||||
*
|
||||
* 提供配置全局代理的输入界面,支持用户名密码认证。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, TestTube2, Search, Eye, EyeOff, X } from "lucide-react";
|
||||
import {
|
||||
useGlobalProxyUrl,
|
||||
useSetGlobalProxyUrl,
|
||||
useTestProxy,
|
||||
useScanProxies,
|
||||
type DetectedProxy,
|
||||
} from "@/hooks/useGlobalProxy";
|
||||
|
||||
/** 从完整 URL 提取认证信息 */
|
||||
function extractAuth(url: string): {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
if (!url.trim()) return { baseUrl: "", username: "", password: "" };
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const username = decodeURIComponent(parsed.username || "");
|
||||
const password = decodeURIComponent(parsed.password || "");
|
||||
// 移除认证信息,获取基础 URL
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return { baseUrl: parsed.toString(), username, password };
|
||||
} catch {
|
||||
return { baseUrl: url, username: "", password: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/** 将认证信息合并到 URL */
|
||||
function mergeAuth(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
if (!baseUrl.trim()) return "";
|
||||
if (!username.trim()) return baseUrl;
|
||||
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
// URL 对象的 username/password setter 会自动进行 percent-encoding
|
||||
// 不要使用 encodeURIComponent,否则会导致双重编码
|
||||
parsed.username = username.trim();
|
||||
if (password) {
|
||||
parsed.password = password;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
// URL 解析失败,尝试手动插入(此时需要手动编码)
|
||||
const match = baseUrl.match(/^(\w+:\/\/)(.+)$/);
|
||||
if (match) {
|
||||
const auth = password
|
||||
? `${encodeURIComponent(username.trim())}:${encodeURIComponent(password)}@`
|
||||
: `${encodeURIComponent(username.trim())}@`;
|
||||
return `${match[1]}${auth}${match[2]}`;
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function GlobalProxySettings() {
|
||||
const { t } = useTranslation();
|
||||
const { data: savedUrl, isLoading } = useGlobalProxyUrl();
|
||||
const setMutation = useSetGlobalProxyUrl();
|
||||
const testMutation = useTestProxy();
|
||||
const scanMutation = useScanProxies();
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [detected, setDetected] = useState<DetectedProxy[]>([]);
|
||||
|
||||
// 计算完整 URL(含认证信息)
|
||||
const fullUrl = useMemo(
|
||||
() => mergeAuth(url, username, password),
|
||||
[url, username, password],
|
||||
);
|
||||
|
||||
// 同步远程配置
|
||||
useEffect(() => {
|
||||
if (savedUrl !== undefined) {
|
||||
const { baseUrl, username: u, password: p } = extractAuth(savedUrl || "");
|
||||
setUrl(baseUrl);
|
||||
setUsername(u);
|
||||
setPassword(p);
|
||||
setDirty(false);
|
||||
}
|
||||
}, [savedUrl]);
|
||||
|
||||
const handleSave = async () => {
|
||||
await setMutation.mutateAsync(fullUrl);
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (fullUrl) {
|
||||
await testMutation.mutateAsync(fullUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
const result = await scanMutation.mutateAsync();
|
||||
setDetected(result);
|
||||
};
|
||||
|
||||
const handleSelect = (proxyUrl: string) => {
|
||||
const { baseUrl, username: u, password: p } = extractAuth(proxyUrl);
|
||||
setUrl(baseUrl);
|
||||
setUsername(u);
|
||||
setPassword(p);
|
||||
setDirty(true);
|
||||
setDetected([]);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setUrl("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && dirty && !setMutation.isPending) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
// 只在首次加载且无数据时显示加载状态
|
||||
if (isLoading && savedUrl === undefined) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 描述 */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.globalProxy.hint")}
|
||||
</p>
|
||||
|
||||
{/* 代理地址输入框和按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={scanMutation.isPending}
|
||||
onClick={handleScan}
|
||||
title={t("settings.globalProxy.scan")}
|
||||
>
|
||||
{scanMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={!fullUrl || testMutation.isPending}
|
||||
onClick={handleTest}
|
||||
title={t("settings.globalProxy.test")}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={!url && !username && !password}
|
||||
onClick={handleClear}
|
||||
title={t("settings.globalProxy.clear")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || setMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{setMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 认证信息:用户名 + 密码(可选) */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t("settings.globalProxy.username")}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t("settings.globalProxy.password")}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-mono text-sm pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 扫描结果 */}
|
||||
{detected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detected.map((p) => (
|
||||
<Button
|
||||
key={p.url}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleSelect(p.url)}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{p.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
|
||||
|
||||
export function RectifierConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<RectifierConfig>({
|
||||
enabled: true,
|
||||
requestThinkingSignature: true,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi
|
||||
.getRectifierConfig()
|
||||
.then(setConfig)
|
||||
.catch((e) => console.error("Failed to load rectifier config:", e))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleChange = async (updates: Partial<RectifierConfig>) => {
|
||||
const newConfig = { ...config, ...updates };
|
||||
setConfig(newConfig);
|
||||
try {
|
||||
await settingsApi.setRectifierConfig(newConfig);
|
||||
} catch (e) {
|
||||
console.error("Failed to save rectifier config:", e);
|
||||
toast.error(String(e));
|
||||
setConfig(config);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.rectifier.enabled")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.enabledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) => handleChange({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.requestGroup")}
|
||||
</h4>
|
||||
<div className="flex items-center justify-between pl-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.rectifier.thinkingSignature")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.thinkingSignatureDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.requestThinkingSignature}
|
||||
disabled={!config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({ requestThinkingSignature: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
Database,
|
||||
Server,
|
||||
ChevronDown,
|
||||
Zap,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { toast } from "sonner";
|
||||
@@ -36,14 +34,12 @@ import { WindowSettings } from "@/components/settings/WindowSettings";
|
||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||
import { ProxyPanel } from "@/components/proxy";
|
||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useImportExport } from "@/hooks/useImportExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -499,28 +495,6 @@ export function SettingsPage({
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="globalProxy"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-5 w-5 text-cyan-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.globalProxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.globalProxy.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<GlobalProxySettings />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="data"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
@@ -552,28 +526,6 @@ export function SettingsPage({
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="rectifier"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.rectifier.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.rectifier.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<RectifierConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="pt-4">
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Download, Loader2, Trash2 } from "lucide-react";
|
||||
import type { MarketplaceBundle } from "@/types/template";
|
||||
import type { AppType } from "@/lib/api/config";
|
||||
|
||||
// 组件类型图标映射
|
||||
const componentTypeIcons: Record<string, string> = {
|
||||
agent: "🤖",
|
||||
command: "⚡",
|
||||
mcp: "🔌",
|
||||
setting: "⚙️",
|
||||
hook: "🪝",
|
||||
skill: "💡",
|
||||
};
|
||||
|
||||
interface BundleInstallStatus {
|
||||
installed: boolean;
|
||||
installedIds: number[];
|
||||
totalCount: number;
|
||||
installedCount: number;
|
||||
}
|
||||
|
||||
interface BundleDetailProps {
|
||||
bundle: MarketplaceBundle;
|
||||
status?: BundleInstallStatus;
|
||||
selectedApp: AppType;
|
||||
onClose: () => void;
|
||||
onInstall: () => void;
|
||||
onUninstall: () => void;
|
||||
installing: boolean;
|
||||
uninstalling: boolean;
|
||||
}
|
||||
|
||||
export function BundleDetail({
|
||||
bundle,
|
||||
status,
|
||||
onClose,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
installing,
|
||||
uninstalling,
|
||||
}: BundleDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 按类型分组组件
|
||||
const componentsByType = bundle.components.reduce(
|
||||
(acc, comp) => {
|
||||
const type = comp.componentType;
|
||||
if (!acc[type]) acc[type] = [];
|
||||
acc[type].push(comp);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof bundle.components>,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-3xl">📦</span>
|
||||
{status?.installed && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
||||
>
|
||||
{t("templates.installed", { defaultValue: "已安装" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<DialogTitle className="text-2xl">{bundle.name}</DialogTitle>
|
||||
<DialogDescription className="text-sm mt-2">
|
||||
{bundle.description ||
|
||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 组件列表 */}
|
||||
<div className="flex-1 overflow-y-auto space-y-6 py-4 px-1">
|
||||
{Object.entries(componentsByType).map(([type, components]) => (
|
||||
<div key={type}>
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<span className="text-xl">
|
||||
{componentTypeIcons[type] || "📦"}
|
||||
</span>
|
||||
<h3 className="font-medium text-foreground">
|
||||
{t(`templates.type.${type}`, { defaultValue: type })}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{components.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 px-2">
|
||||
{components.map((comp, idx) => (
|
||||
<div
|
||||
key={`${comp.name}-${idx}`}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-muted/30"
|
||||
>
|
||||
<span className="text-lg">
|
||||
{componentTypeIcons[type] || "📦"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{comp.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{comp.path}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.close", { defaultValue: "关闭" })}
|
||||
</Button>
|
||||
{status && status.installedCount > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onUninstall}
|
||||
disabled={uninstalling}
|
||||
>
|
||||
{uninstalling ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
|
||||
</Button>
|
||||
)}
|
||||
{!status?.installed && (
|
||||
<Button onClick={onInstall} disabled={installing}>
|
||||
{installing ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t("templates.bundle.install", { defaultValue: "安装组合" })}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Download, Loader2, Package, Trash2, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useMarketplaceBundles,
|
||||
useBatchInstallComponents,
|
||||
} from "@/lib/query/template";
|
||||
import { templateApi } from "@/lib/api/template";
|
||||
import { BundleDetail } from "./BundleDetail";
|
||||
import type { MarketplaceBundle, ComponentType } from "@/types/template";
|
||||
import type { AppType } from "@/lib/api/config";
|
||||
|
||||
// 组件类型图标映射
|
||||
const componentTypeIcons: Record<string, string> = {
|
||||
agent: "🤖",
|
||||
command: "⚡",
|
||||
mcp: "🔌",
|
||||
setting: "⚙️",
|
||||
hook: "🪝",
|
||||
skill: "💡",
|
||||
};
|
||||
|
||||
interface BundleInstallStatus {
|
||||
installed: boolean;
|
||||
installedIds: number[];
|
||||
totalCount: number;
|
||||
installedCount: number;
|
||||
}
|
||||
|
||||
interface BundleListProps {
|
||||
selectedApp: AppType;
|
||||
}
|
||||
|
||||
// 统计组件类型数量
|
||||
function getComponentTypeCounts(components: MarketplaceBundle["components"]) {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const comp of components) {
|
||||
const type = comp.componentType;
|
||||
counts[type] = (counts[type] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function BundleList({ selectedApp }: BundleListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [installingBundle, setInstallingBundle] = useState<string | null>(null);
|
||||
const [uninstallingBundle, setUninstallingBundle] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [bundleStatuses, setBundleStatuses] = useState<
|
||||
Record<string, BundleInstallStatus>
|
||||
>({});
|
||||
const [detailBundle, setDetailBundle] = useState<MarketplaceBundle | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: bundles = [], isLoading } = useMarketplaceBundles();
|
||||
const batchInstallMutation = useBatchInstallComponents();
|
||||
|
||||
// 检查组合安装状态
|
||||
const checkBundleStatus = useCallback(
|
||||
async (bundle: MarketplaceBundle): Promise<BundleInstallStatus> => {
|
||||
const componentsByType = bundle.components.reduce(
|
||||
(acc, comp) => {
|
||||
const type = comp.componentType;
|
||||
if (!acc[type]) acc[type] = [];
|
||||
acc[type].push(comp.name.toLowerCase());
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
);
|
||||
|
||||
const installedIds: number[] = [];
|
||||
let totalMatched = 0;
|
||||
|
||||
for (const [componentType, names] of Object.entries(componentsByType)) {
|
||||
const componentsData = await templateApi.listTemplateComponents({
|
||||
componentType: componentType as ComponentType,
|
||||
pageSize: 1000,
|
||||
appType: selectedApp,
|
||||
});
|
||||
|
||||
for (const comp of componentsData.items) {
|
||||
if (names.includes(comp.name.toLowerCase())) {
|
||||
totalMatched++;
|
||||
if (comp.installed && comp.id !== null) {
|
||||
installedIds.push(comp.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed:
|
||||
installedIds.length > 0 && installedIds.length === totalMatched,
|
||||
installedIds,
|
||||
totalCount: totalMatched,
|
||||
installedCount: installedIds.length,
|
||||
};
|
||||
},
|
||||
[selectedApp],
|
||||
);
|
||||
|
||||
// 加载所有组合的安装状态
|
||||
useEffect(() => {
|
||||
const loadStatuses = async () => {
|
||||
const statuses: Record<string, BundleInstallStatus> = {};
|
||||
for (const bundle of bundles) {
|
||||
statuses[bundle.id] = await checkBundleStatus(bundle);
|
||||
}
|
||||
setBundleStatuses(statuses);
|
||||
};
|
||||
if (bundles.length > 0) {
|
||||
loadStatuses();
|
||||
}
|
||||
}, [bundles, checkBundleStatus]);
|
||||
|
||||
const handleInstallBundle = async (bundle: MarketplaceBundle) => {
|
||||
setInstallingBundle(bundle.id);
|
||||
try {
|
||||
// 按组件类型分组
|
||||
const componentsByType = bundle.components.reduce(
|
||||
(acc, comp) => {
|
||||
const type = comp.componentType;
|
||||
if (!acc[type]) acc[type] = [];
|
||||
acc[type].push(comp.name.toLowerCase());
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
);
|
||||
|
||||
// 收集所有匹配的组件 ID
|
||||
const matchedIds: number[] = [];
|
||||
|
||||
for (const [componentType, names] of Object.entries(componentsByType)) {
|
||||
const componentsData = await templateApi.listTemplateComponents({
|
||||
componentType: componentType as ComponentType,
|
||||
pageSize: 1000,
|
||||
});
|
||||
|
||||
const ids = componentsData.items
|
||||
.filter((c) => names.includes(c.name.toLowerCase()))
|
||||
.map((c) => c.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
matchedIds.push(...ids);
|
||||
}
|
||||
|
||||
if (matchedIds.length === 0) {
|
||||
toast.warning(
|
||||
t("templates.bundle.noMatch", {
|
||||
defaultValue: "未找到匹配的组件",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await batchInstallMutation.mutateAsync({
|
||||
ids: matchedIds,
|
||||
appType: selectedApp,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t("templates.bundle.installSuccess", {
|
||||
count: result.success.length,
|
||||
defaultValue: `已安装 ${result.success.length} 个组件`,
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.failed.length > 0) {
|
||||
toast.warning(
|
||||
t("templates.bundle.partialFail", {
|
||||
count: result.failed.length,
|
||||
defaultValue: `${result.failed.length} 个组件安装失败`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新安装状态
|
||||
const newStatus = await checkBundleStatus(bundle);
|
||||
setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("templates.bundle.installFailed", { defaultValue: "安装组合失败" }),
|
||||
{ description: errorMessage, duration: 8000 },
|
||||
);
|
||||
} finally {
|
||||
setInstallingBundle(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstallBundle = async (bundle: MarketplaceBundle) => {
|
||||
const status = bundleStatuses[bundle.id];
|
||||
if (!status || status.installedIds.length === 0) return;
|
||||
|
||||
setUninstallingBundle(bundle.id);
|
||||
try {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const id of status.installedIds) {
|
||||
try {
|
||||
await templateApi.uninstallTemplateComponent(id, selectedApp);
|
||||
successCount++;
|
||||
} catch {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
t("templates.bundle.uninstallSuccess", {
|
||||
count: successCount,
|
||||
defaultValue: `已卸载 ${successCount} 个组件`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (failCount > 0) {
|
||||
toast.warning(
|
||||
t("templates.bundle.uninstallPartialFail", {
|
||||
count: failCount,
|
||||
defaultValue: `${failCount} 个组件卸载失败`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新安装状态
|
||||
const newStatus = await checkBundleStatus(bundle);
|
||||
setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("templates.bundle.uninstallFailed", { defaultValue: "卸载组合失败" }),
|
||||
{ description: errorMessage, duration: 8000 },
|
||||
);
|
||||
} finally {
|
||||
setUninstallingBundle(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bundles.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
{t("templates.bundle.empty", { defaultValue: "暂无组合" })}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("templates.bundle.emptyDescription", {
|
||||
defaultValue: "请添加包含 components.json 的模板仓库",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{bundles.map((bundle) => {
|
||||
const typeCounts = getComponentTypeCounts(bundle.components);
|
||||
const status = bundleStatuses[bundle.id];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bundle.id}
|
||||
className="glass-card rounded-xl p-4 flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer"
|
||||
onClick={() => setDetailBundle(bundle)}
|
||||
>
|
||||
<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" />
|
||||
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-2xl">📦</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground truncate">
|
||||
{bundle.name}
|
||||
</h3>
|
||||
</div>
|
||||
{status?.installed && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
||||
>
|
||||
{t("templates.installed", { defaultValue: "已安装" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="text-sm text-muted-foreground/90 line-clamp-2 leading-relaxed mb-3 flex-1">
|
||||
{bundle.description ||
|
||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
||||
</p>
|
||||
|
||||
{/* 组件类型统计 */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{Object.entries(typeCounts).map(([type, count]) => (
|
||||
<Badge key={type} variant="secondary" className="text-xs">
|
||||
<span className="mr-1">
|
||||
{componentTypeIcons[type] || "📦"}
|
||||
</span>
|
||||
{type} {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailBundle(bundle);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t("templates.viewDetail", { defaultValue: "查看详情" })}
|
||||
</Button>
|
||||
|
||||
{status && status.installedCount > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUninstallBundle(bundle);
|
||||
}}
|
||||
disabled={uninstallingBundle === bundle.id}
|
||||
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
|
||||
>
|
||||
{uninstallingBundle === bundle.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
|
||||
</Button>
|
||||
)}
|
||||
{!status?.installed && (
|
||||
<Button
|
||||
variant="mcp"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleInstallBundle(bundle);
|
||||
}}
|
||||
disabled={installingBundle === bundle.id}
|
||||
className="flex-1"
|
||||
>
|
||||
{installingBundle === bundle.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{t("templates.bundle.install", { defaultValue: "安装" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{detailBundle && (
|
||||
<BundleDetail
|
||||
bundle={detailBundle}
|
||||
status={bundleStatuses[detailBundle.id]}
|
||||
selectedApp={selectedApp}
|
||||
onClose={() => setDetailBundle(null)}
|
||||
onInstall={() => handleInstallBundle(detailBundle)}
|
||||
onUninstall={() => handleUninstallBundle(detailBundle)}
|
||||
installing={installingBundle === detailBundle.id}
|
||||
uninstalling={uninstallingBundle === detailBundle.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface CategoryFilterProps {
|
||||
categories: string[];
|
||||
selectedCategory?: string;
|
||||
onSelectCategory: (category: string | undefined) => void;
|
||||
}
|
||||
|
||||
export function CategoryFilter({
|
||||
categories,
|
||||
selectedCategory,
|
||||
onSelectCategory,
|
||||
}: CategoryFilterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-xl p-4 sticky top-0">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t("templates.category.title", { defaultValue: "分类" })}
|
||||
</h3>
|
||||
<div className="h-[calc(100vh-16rem)] overflow-y-auto">
|
||||
<div className="space-y-1 pr-2">
|
||||
{/* 全部选项 */}
|
||||
<Button
|
||||
variant={selectedCategory === undefined ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onSelectCategory(undefined)}
|
||||
className="w-full justify-start text-sm h-9"
|
||||
>
|
||||
{t("templates.category.all", { defaultValue: "全部" })}
|
||||
{selectedCategory === undefined && (
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
✓
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 分类列表 */}
|
||||
{categories.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-2" />
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={
|
||||
selectedCategory === category ? "secondary" : "ghost"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => onSelectCategory(category)}
|
||||
className="w-full justify-start text-sm h-9"
|
||||
>
|
||||
<span className="truncate">{category}</span>
|
||||
{selectedCategory === category && (
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
✓
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 无分类提示 */}
|
||||
{categories.length === 0 && selectedCategory === undefined && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
{t("templates.category.empty", { defaultValue: "暂无分类" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Download, Trash2, Loader2, FileText } from "lucide-react";
|
||||
import type { TemplateComponent } from "@/types/template";
|
||||
|
||||
interface ComponentCardProps {
|
||||
component: TemplateComponent;
|
||||
onInstall: () => Promise<void>;
|
||||
onUninstall: () => Promise<void>;
|
||||
onViewDetail: () => void;
|
||||
}
|
||||
|
||||
// 组件类型图标映射
|
||||
const componentTypeIcons: Record<string, string> = {
|
||||
agent: "🤖",
|
||||
command: "⚡",
|
||||
mcp: "🔌",
|
||||
setting: "⚙️",
|
||||
hook: "🪝",
|
||||
skill: "💡",
|
||||
};
|
||||
|
||||
export function ComponentCard({
|
||||
component,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onViewDetail,
|
||||
}: ComponentCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleInstall = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onInstall();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onUninstall();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const typeIcon = componentTypeIcons[component.componentType] || "📦";
|
||||
|
||||
return (
|
||||
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer">
|
||||
<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" />
|
||||
|
||||
<div onClick={onViewDetail}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-2xl">{typeIcon}</span>
|
||||
</div>
|
||||
<CardTitle className="text-base font-semibold truncate">
|
||||
{component.name}
|
||||
</CardTitle>
|
||||
{component.category && (
|
||||
<CardDescription className="text-xs mt-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 h-4 border-border-default"
|
||||
>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{component.installed && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
||||
>
|
||||
{t("templates.installed", { defaultValue: "已安装" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pt-0">
|
||||
<p className="text-sm text-muted-foreground/90 line-clamp-3 leading-relaxed">
|
||||
{component.description ||
|
||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
<CardFooter className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetail();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t("templates.viewDetail", { defaultValue: "查看详情" })}
|
||||
</Button>
|
||||
|
||||
{component.installed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUninstall();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{loading
|
||||
? t("templates.uninstalling", { defaultValue: "卸载中..." })
|
||||
: t("templates.uninstall", { defaultValue: "卸载" })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="mcp"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleInstall();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{loading
|
||||
? t("templates.installing", { defaultValue: "安装中..." })
|
||||
: t("templates.install", { defaultValue: "安装" })}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
FileCode,
|
||||
FolderGit2,
|
||||
Tag,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import {
|
||||
useTemplateComponent,
|
||||
useComponentPreview,
|
||||
} from "@/lib/query/template";
|
||||
import type { AppType } from "@/lib/api/config";
|
||||
|
||||
interface ComponentDetailProps {
|
||||
componentId: number;
|
||||
selectedApp: AppType;
|
||||
onClose: () => void;
|
||||
onInstall: (id: number, name: string) => Promise<void>;
|
||||
onUninstall: (id: number, name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// 组件类型图标映射
|
||||
const componentTypeIcons: Record<string, string> = {
|
||||
agent: "🤖",
|
||||
command: "⚡",
|
||||
mcp: "🔌",
|
||||
setting: "⚙️",
|
||||
hook: "🪝",
|
||||
skill: "💡",
|
||||
};
|
||||
|
||||
export function ComponentDetail({
|
||||
componentId,
|
||||
onClose,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
}: ComponentDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data: component, isLoading: componentLoading } =
|
||||
useTemplateComponent(componentId);
|
||||
const { data: preview, isLoading: previewLoading } =
|
||||
useComponentPreview(componentId);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!component || component.id === null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onInstall(component.id, component.name);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async () => {
|
||||
if (!component || component.id === null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onUninstall(component.id, component.name);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenGithub = async () => {
|
||||
if (component?.readmeUrl) {
|
||||
try {
|
||||
await settingsApi.openExternal(component.readmeUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (componentLoading || !component) {
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const typeIcon = componentTypeIcons[component.componentType] || "📦";
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-3xl">{typeIcon}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t(`templates.type.${component.componentType}`, {
|
||||
defaultValue: component.componentType,
|
||||
})}
|
||||
</Badge>
|
||||
{component.category && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{component.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<DialogTitle className="text-2xl">{component.name}</DialogTitle>
|
||||
<DialogDescription className="text-sm mt-2">
|
||||
{component.description ||
|
||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{component.installed && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="shrink-0 bg-green-600/90 text-white border-0"
|
||||
>
|
||||
{t("templates.installed", { defaultValue: "已安装" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs defaultValue="info" className="h-full flex flex-col">
|
||||
<TabsList>
|
||||
<TabsTrigger value="info">
|
||||
{t("templates.detail.info", { defaultValue: "信息" })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview">
|
||||
<FileCode className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t("templates.detail.preview", { defaultValue: "预览" })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info" className="flex-1 overflow-y-auto mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* 类型 */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<Tag className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("templates.detail.type", { defaultValue: "类型" })}
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate">
|
||||
{t(`templates.type.${component.componentType}`, {
|
||||
defaultValue: component.componentType,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类 */}
|
||||
{component.category && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<Tag className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("templates.detail.category", {
|
||||
defaultValue: "分类",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate">
|
||||
{component.category}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 仓库 */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<FolderGit2 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("templates.detail.repository", {
|
||||
defaultValue: "仓库",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{component.repoOwner}/{component.repoName}
|
||||
{component.repoBranch &&
|
||||
component.repoBranch !== "main" &&
|
||||
` (${component.repoBranch})`}
|
||||
</p>
|
||||
{component.readmeUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleOpenGithub}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更新时间 */}
|
||||
{component.updatedAt && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("templates.detail.updatedAt", {
|
||||
defaultValue: "更新时间",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate">
|
||||
{new Date(component.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 路径 */}
|
||||
<div className="mt-3 p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{t("templates.detail.path", { defaultValue: "路径" })}
|
||||
</p>
|
||||
<p className="text-sm font-mono text-muted-foreground break-all">
|
||||
{component.path}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="preview"
|
||||
className="flex-1 overflow-y-auto mt-4"
|
||||
>
|
||||
{previewLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : preview ? (
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-auto font-mono whitespace-pre-wrap break-words">
|
||||
{preview}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{t("templates.detail.noPreview", {
|
||||
defaultValue: "无法预览内容",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.close", { defaultValue: "关闭" })}
|
||||
</Button>
|
||||
{component.installed ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUninstall}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{loading
|
||||
? t("templates.uninstalling", { defaultValue: "卸载中..." })
|
||||
: t("templates.uninstall", { defaultValue: "卸载" })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleInstall} disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{loading
|
||||
? t("templates.installing", { defaultValue: "安装中..." })
|
||||
: t("templates.install", { defaultValue: "安装" })}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Trash2, ExternalLink, Plus, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import {
|
||||
useTemplateRepos,
|
||||
useAddTemplateRepo,
|
||||
useRemoveTemplateRepo,
|
||||
useToggleTemplateRepo,
|
||||
} from "@/lib/query/template";
|
||||
|
||||
interface RepoManagerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RepoManager({ onClose }: RepoManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const { data: repos = [], isLoading } = useTemplateRepos();
|
||||
const addRepoMutation = useAddTemplateRepo();
|
||||
const removeRepoMutation = useRemoveTemplateRepo();
|
||||
const toggleRepoMutation = useToggleTemplateRepo();
|
||||
|
||||
const parseRepoUrl = (
|
||||
url: string,
|
||||
): { owner: string; name: string } | null => {
|
||||
let cleaned = url.trim();
|
||||
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
|
||||
cleaned = cleaned.replace(/\.git$/, "");
|
||||
|
||||
const parts = cleaned.split("/");
|
||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||
return { owner: parts[0], name: parts[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
setError("");
|
||||
|
||||
const parsed = parseRepoUrl(repoUrl);
|
||||
if (!parsed) {
|
||||
setError(
|
||||
t("templates.repo.invalidUrl", {
|
||||
defaultValue: "仓库地址格式不正确",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addRepoMutation.mutateAsync({
|
||||
owner: parsed.owner,
|
||||
name: parsed.name,
|
||||
branch: branch || "main",
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t("templates.repo.addSuccess", {
|
||||
owner: parsed.owner,
|
||||
name: parsed.name,
|
||||
defaultValue: `已添加仓库 ${parsed.owner}/${parsed.name}`,
|
||||
}),
|
||||
);
|
||||
|
||||
setRepoUrl("");
|
||||
setBranch("");
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
setError(
|
||||
t("templates.repo.addFailed", {
|
||||
defaultValue: "添加仓库失败",
|
||||
}),
|
||||
);
|
||||
toast.error(
|
||||
t("templates.repo.addFailed", { defaultValue: "添加仓库失败" }),
|
||||
{
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (id: number, owner: string, name: string) => {
|
||||
try {
|
||||
await removeRepoMutation.mutateAsync(id);
|
||||
toast.success(
|
||||
t("templates.repo.removeSuccess", {
|
||||
owner,
|
||||
name,
|
||||
defaultValue: `已移除仓库 ${owner}/${name}`,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
toast.error(
|
||||
t("templates.repo.removeFailed", { defaultValue: "移除仓库失败" }),
|
||||
{
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: number, enabled: boolean) => {
|
||||
try {
|
||||
await toggleRepoMutation.mutateAsync({ id, enabled });
|
||||
toast.success(
|
||||
enabled
|
||||
? t("templates.repo.enableSuccess", { defaultValue: "已启用仓库" })
|
||||
: t("templates.repo.disableSuccess", { defaultValue: "已禁用仓库" }),
|
||||
);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
toast.error(
|
||||
t("templates.repo.toggleFailed", {
|
||||
defaultValue: "切换仓库状态失败",
|
||||
}),
|
||||
{
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRepo = async (owner: string, name: string) => {
|
||||
try {
|
||||
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={true}
|
||||
title={t("templates.repo.title", { defaultValue: "模板仓库管理" })}
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* 添加仓库表单 */}
|
||||
<div className="space-y-4 glass-card rounded-xl p-6">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("templates.repo.addTitle", { defaultValue: "添加模板仓库" })}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="repo-url" className="text-foreground">
|
||||
{t("templates.repo.url", { defaultValue: "仓库地址" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="repo-url"
|
||||
placeholder={t("templates.repo.urlPlaceholder", {
|
||||
defaultValue: "owner/repo 或 https://github.com/owner/repo",
|
||||
})}
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="branch" className="text-foreground">
|
||||
{t("templates.repo.branch", { defaultValue: "分支" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="branch"
|
||||
placeholder={t("templates.repo.branchPlaceholder", {
|
||||
defaultValue: "main",
|
||||
})}
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={addRepoMutation.isPending}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
type="button"
|
||||
>
|
||||
{addRepoMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t("templates.repo.add", { defaultValue: "添加仓库" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 仓库列表 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("templates.repo.list", { defaultValue: "仓库列表" })}
|
||||
</h3>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 glass-card rounded-xl">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : repos.length === 0 ? (
|
||||
<div className="text-center py-12 glass-card rounded-xl">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("templates.repo.empty", { defaultValue: "暂无仓库" })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{repos.map((repo) => (
|
||||
<div
|
||||
key={repo.id ?? `${repo.owner}-${repo.name}`}
|
||||
className="flex items-center justify-between glass-card rounded-xl px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<Switch
|
||||
checked={repo.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
repo.id !== null && handleToggle(repo.id, checked)
|
||||
}
|
||||
disabled={toggleRepoMutation.isPending || repo.id === null}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{repo.owner}/{repo.name}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("templates.repo.branch", { defaultValue: "分支" })}:{" "}
|
||||
{repo.branch}
|
||||
</span>
|
||||
<Badge
|
||||
variant={repo.enabled ? "default" : "secondary"}
|
||||
className="text-[10px] px-1.5 py-0 h-4"
|
||||
>
|
||||
{repo.enabled
|
||||
? t("templates.repo.enabled", {
|
||||
defaultValue: "已启用",
|
||||
})
|
||||
: t("templates.repo.disabled", {
|
||||
defaultValue: "已禁用",
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
||||
title={t("common.view", { defaultValue: "查看" })}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
repo.id !== null &&
|
||||
handleRemove(repo.id, repo.owner, repo.name)
|
||||
}
|
||||
disabled={removeRepoMutation.isPending || repo.id === null}
|
||||
title={t("common.delete", { defaultValue: "删除" })}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { RefreshCw, Search, Settings, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ComponentCard } from "./ComponentCard";
|
||||
import { ComponentDetail } from "./ComponentDetail";
|
||||
import { CategoryFilter } from "./CategoryFilter";
|
||||
import { RepoManager } from "./RepoManager";
|
||||
import { BundleList } from "./BundleList";
|
||||
import {
|
||||
useTemplateComponents,
|
||||
useComponentCategories,
|
||||
useInstallTemplateComponent,
|
||||
useUninstallTemplateComponent,
|
||||
useRefreshTemplateIndex,
|
||||
} from "@/lib/query/template";
|
||||
import type { ComponentType, TemplateComponent } from "@/types/template";
|
||||
import type { AppType } from "@/lib/api/config";
|
||||
|
||||
interface TemplatesPageProps {
|
||||
activeApp: AppType;
|
||||
}
|
||||
|
||||
export function TemplatesPage({ activeApp }: TemplatesPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedType, setSelectedType] = useState<ComponentType | "bundle">(
|
||||
"bundle",
|
||||
);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||
const [detailComponent, setDetailComponent] = useState<
|
||||
TemplateComponent | undefined
|
||||
>(undefined);
|
||||
|
||||
// Queries
|
||||
const {
|
||||
data: componentsData,
|
||||
isLoading: componentsLoading,
|
||||
refetch: refetchComponents,
|
||||
} = useTemplateComponents({
|
||||
componentType: selectedType === "bundle" ? undefined : selectedType,
|
||||
category: selectedCategory,
|
||||
search: searchQuery || undefined,
|
||||
appType: activeApp,
|
||||
});
|
||||
|
||||
const { data: categories = [] } = useComponentCategories(
|
||||
selectedType === "bundle" ? undefined : selectedType,
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const installMutation = useInstallTemplateComponent();
|
||||
const uninstallMutation = useUninstallTemplateComponent();
|
||||
const refreshMutation = useRefreshTemplateIndex();
|
||||
|
||||
const handleInstall = async (id: number, name: string) => {
|
||||
try {
|
||||
await installMutation.mutateAsync({ id, appType: activeApp });
|
||||
toast.success(
|
||||
t("templates.installSuccess", { name, defaultValue: `已安装 ${name}` }),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("templates.installFailed", {
|
||||
name,
|
||||
defaultValue: `安装 ${name} 失败`,
|
||||
}),
|
||||
{
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
console.error("Install component failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (id: number, name: string) => {
|
||||
try {
|
||||
await uninstallMutation.mutateAsync({ id, appType: activeApp });
|
||||
toast.success(
|
||||
t("templates.uninstallSuccess", {
|
||||
name,
|
||||
defaultValue: `已卸载 ${name}`,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("templates.uninstallFailed", {
|
||||
name,
|
||||
defaultValue: `卸载 ${name} 失败`,
|
||||
}),
|
||||
{
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
console.error("Uninstall component failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
await refreshMutation.mutateAsync();
|
||||
await refetchComponents();
|
||||
toast.success(
|
||||
t("templates.refreshSuccess", { defaultValue: "刷新成功" }),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t("templates.refreshFailed", { defaultValue: "刷新失败" }), {
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
});
|
||||
console.error("Refresh index failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const components = componentsData?.items || [];
|
||||
|
||||
// 过滤组件
|
||||
const filteredComponents = useMemo(() => {
|
||||
if (!searchQuery.trim()) return components;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return components.filter((component) => {
|
||||
const name = component.name?.toLowerCase() || "";
|
||||
const description = component.description?.toLowerCase() || "";
|
||||
const category = component.category?.toLowerCase() || "";
|
||||
|
||||
return (
|
||||
name.includes(query) ||
|
||||
description.includes(query) ||
|
||||
category.includes(query)
|
||||
);
|
||||
});
|
||||
}, [components, searchQuery]);
|
||||
|
||||
const componentTypeOptions: {
|
||||
value: ComponentType | "bundle";
|
||||
icon: string;
|
||||
}[] = [
|
||||
{ value: "bundle", icon: "📦" },
|
||||
{ value: "agent", icon: "🤖" },
|
||||
{ value: "command", icon: "⚡" },
|
||||
{ value: "mcp", icon: "🔌" },
|
||||
{ value: "setting", icon: "⚙️" },
|
||||
{ value: "hook", icon: "🪝" },
|
||||
{ value: "skill", icon: "💡" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[80rem] px-6 flex h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
|
||||
{/* 左侧分类过滤器 */}
|
||||
<div className="w-48 shrink-0 mr-6 overflow-y-auto">
|
||||
<CategoryFilter
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={setSelectedCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧主内容区 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="mb-6 space-y-4">
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshMutation.isPending}
|
||||
>
|
||||
{refreshMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t("templates.refresh", { defaultValue: "刷新索引" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRepoManagerOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t("templates.manageRepos", { defaultValue: "管理仓库" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("templates.searchPlaceholder", {
|
||||
defaultValue: "搜索组件...",
|
||||
})}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型标签页 */}
|
||||
<Tabs
|
||||
value={selectedType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedType(value as ComponentType | "bundle");
|
||||
setSelectedCategory(undefined);
|
||||
}}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full justify-start mb-4">
|
||||
{componentTypeOptions.map((option) => (
|
||||
<TabsTrigger key={option.value} value={option.value}>
|
||||
<span className="mr-1.5">{option.icon}</span>
|
||||
{t(`templates.type.${option.value}`, {
|
||||
defaultValue: option.value,
|
||||
})}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* 组合标签页 */}
|
||||
<TabsContent value="bundle" className="flex-1 overflow-y-auto mt-0">
|
||||
<div className="py-4">
|
||||
<BundleList selectedApp={activeApp} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 组件类型标签页 */}
|
||||
{componentTypeOptions
|
||||
.filter((opt) => opt.value !== "bundle")
|
||||
.map((option) => (
|
||||
<TabsContent
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="flex-1 overflow-y-auto mt-0"
|
||||
>
|
||||
<div className="py-4">
|
||||
{componentsLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredComponents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("templates.empty", { defaultValue: "暂无组件" })}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("templates.emptyDescription", {
|
||||
defaultValue: "请添加模板仓库来获取组件",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{searchQuery && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{t("templates.count", {
|
||||
count: filteredComponents.length,
|
||||
defaultValue: `找到 ${filteredComponents.length} 个组件`,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredComponents.map((component) => (
|
||||
<ComponentCard
|
||||
key={
|
||||
component.id ??
|
||||
`${component.repoId}-${component.path}`
|
||||
}
|
||||
component={component}
|
||||
onInstall={async () => {
|
||||
if (component.id !== null) {
|
||||
await handleInstall(
|
||||
component.id,
|
||||
component.name,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onUninstall={async () => {
|
||||
if (component.id !== null) {
|
||||
await handleUninstall(
|
||||
component.id,
|
||||
component.name,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onViewDetail={() => setDetailComponent(component)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 仓库管理弹窗 */}
|
||||
{repoManagerOpen && (
|
||||
<RepoManager onClose={() => setRepoManagerOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* 组件详情弹窗 */}
|
||||
{detailComponent && detailComponent.id !== null && (
|
||||
<ComponentDetail
|
||||
componentId={detailComponent.id}
|
||||
selectedApp={activeApp}
|
||||
onClose={() => setDetailComponent(undefined)}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { TemplatesPage } from "./TemplatesPage";
|
||||
export { ComponentCard } from "./ComponentCard";
|
||||
export { ComponentDetail } from "./ComponentDetail";
|
||||
export { CategoryFilter } from "./CategoryFilter";
|
||||
export { RepoManager } from "./RepoManager";
|
||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
||||
"bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||
// 危险按钮:红底白字(对应旧版 danger)
|
||||
destructive:
|
||||
"bg-red-500 text-white hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600",
|
||||
"bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700",
|
||||
// 轮廓按钮
|
||||
outline:
|
||||
"border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Save, Loader2 } from "lucide-react";
|
||||
@@ -18,15 +17,13 @@ export function ModelTestConfigPanel() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 使用字符串状态以支持完全清空数字输入框
|
||||
const [config, setConfig] = useState({
|
||||
timeoutSecs: "45",
|
||||
maxRetries: "2",
|
||||
degradedThresholdMs: "6000",
|
||||
const [config, setConfig] = useState<StreamCheckConfig>({
|
||||
timeoutSecs: 45,
|
||||
maxRetries: 2,
|
||||
degradedThresholdMs: 6000,
|
||||
claudeModel: "claude-haiku-4-5-20251001",
|
||||
codexModel: "gpt-5.1-codex@low",
|
||||
geminiModel: "gemini-3-pro-preview",
|
||||
testPrompt: "Who are you?",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,15 +35,7 @@ export function ModelTestConfigPanel() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await getStreamCheckConfig();
|
||||
setConfig({
|
||||
timeoutSecs: String(data.timeoutSecs),
|
||||
maxRetries: String(data.maxRetries),
|
||||
degradedThresholdMs: String(data.degradedThresholdMs),
|
||||
claudeModel: data.claudeModel,
|
||||
codexModel: data.codexModel,
|
||||
geminiModel: data.geminiModel,
|
||||
testPrompt: data.testPrompt || "Who are you?",
|
||||
});
|
||||
setConfig(data);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -55,23 +44,9 @@ export function ModelTestConfigPanel() {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// 解析数字,空值使用默认值,0 是有效值
|
||||
const parseNum = (val: string, defaultVal: number) => {
|
||||
const n = parseInt(val);
|
||||
return isNaN(n) ? defaultVal : n;
|
||||
};
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const parsed: StreamCheckConfig = {
|
||||
timeoutSecs: parseNum(config.timeoutSecs, 45),
|
||||
maxRetries: parseNum(config.maxRetries, 2),
|
||||
degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000),
|
||||
claudeModel: config.claudeModel,
|
||||
codexModel: config.codexModel,
|
||||
geminiModel: config.geminiModel,
|
||||
testPrompt: config.testPrompt || "Who are you?",
|
||||
};
|
||||
await saveStreamCheckConfig(parsed);
|
||||
await saveStreamCheckConfig(config);
|
||||
toast.success(t("streamCheck.configSaved"), {
|
||||
closeButton: true,
|
||||
});
|
||||
@@ -157,7 +132,10 @@ export function ModelTestConfigPanel() {
|
||||
max={120}
|
||||
value={config.timeoutSecs}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, timeoutSecs: e.target.value })
|
||||
setConfig({
|
||||
...config,
|
||||
timeoutSecs: parseInt(e.target.value) || 45,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -171,7 +149,10 @@ export function ModelTestConfigPanel() {
|
||||
max={5}
|
||||
value={config.maxRetries}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, maxRetries: e.target.value })
|
||||
setConfig({
|
||||
...config,
|
||||
maxRetries: parseInt(e.target.value) || 2,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -188,26 +169,14 @@ export function ModelTestConfigPanel() {
|
||||
step={1000}
|
||||
value={config.degradedThresholdMs}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, degradedThresholdMs: e.target.value })
|
||||
setConfig({
|
||||
...config,
|
||||
degradedThresholdMs: parseInt(e.target.value) || 6000,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检查提示词配置 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
|
||||
<Textarea
|
||||
id="testPrompt"
|
||||
value={config.testPrompt}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, testPrompt: e.target.value })
|
||||
}
|
||||
placeholder="Who are you?"
|
||||
rows={2}
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user