Compare commits

...

8 Commits

Author SHA1 Message Date
YoVinchen 2eb1670e93 style: fix code formatting and defer templates migration 2026-01-14 11:42:47 +08:00
YoVinchen 929ac54cc9 Merge branch 'main' into feature/claude-code-templates
# Conflicts:
#	src-tauri/src/database/dao/mod.rs
#	src-tauri/src/database/schema.rs
#	src-tauri/src/lib.rs
#	src/App.tsx
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/zh.json
2026-01-14 00:45:20 +08:00
Dex Miller f3343992f2 feat(proxy): add thinking signature rectifier for Claude API (#595)
* feat(proxy): add thinking signature rectifier for Claude API

Add automatic request rectification when Anthropic API returns signature
validation errors. This improves compatibility when switching between
different Claude providers or when historical messages contain incompatible
thinking block signatures.

- Add thinking_rectifier.rs module with trigger detection and rectification
- Integrate rectifier into forwarder error handling flow
- Remove thinking/redacted_thinking blocks and signature fields on retry
- Delete top-level thinking field when assistant message lacks thinking prefix

* fix(proxy): complete rectifier retry path with failover switch and chain continuation

- Add failover switch trigger on rectifier retry success when provider differs from start
- Replace direct error return with error categorization on rectifier retry failure
- Continue failover chain for retryable errors instead of terminating early

* feat(proxy): add rectifier config with master switch

- Add RectifierConfig struct with enabled and requestThinkingSignature fields
- Update should_rectify_thinking_signature to check master switch first
- Add tests for master switch functionality

* feat(db): add rectifier config storage in settings table

Store rectifier config as JSON in single key for extensibility

* feat(commands): add get/set rectifier config commands

* feat(ui): add rectifier config panel in advanced settings

- Add RectifierConfigPanel component with master switch and thinking signature toggle
- Add API wrapper for rectifier config
- Add i18n translations for zh/en/ja

* feat(proxy): integrate rectifier config into request forwarding

- Load rectifier config from database in RequestContext
- Pass config to RequestForwarder for runtime checking
- Use should_rectify_thinking_signature with config parameter

* test(proxy): add nested JSON error detection test for thinking rectifier

* fix(proxy): resolve HalfOpen permit leak and RectifierConfig default values

- Fix RectifierConfig::default() to return enabled=true (was false due to derive)
- Add release_permit_neutral() for releasing permits without affecting health stats
- Fix 3 permit leak points in rectifier retry branches
- Add unit tests for default values and permit release

* style(ui): format ProviderCard style attribute

* fix(rectifier): add detection for signature field required error

Add support for detecting "signature: Field required" error pattern
in the thinking signature rectifier. This enables automatic request
rectification when upstream API returns this specific validation error.
2026-01-14 00:12:13 +08:00
Jason 53f40b2d7a refactor(ui): unify pricing edit modal with FullScreenPanel
Replace Dialog component with FullScreenPanel in PricingEditModal
to match the UI style of other edit dialogs (provider, MCP).

Changes:
- Switch from small centered Dialog to full-screen panel
- Add back button in header and fixed footer for actions
- Add Save/Plus icons to submit button
2026-01-13 15:13:33 +08:00
Dex Miller 1393f89797 feat(misc): add WSL tool version detection with security hardening (#… (#627)
* feat(misc): add WSL tool version detection with security hardening (#608)

- Add WSL distro detection from UNC path (wsl$/wsl.localhost)
- Add distro name validation to prevent command injection
- Add defensive assertion for tool parameter
- Unify cfg macros to target_os = "windows"
- Standardize error message format to [WSL:{distro}]

Closes #608

* fix(misc): add CREATE_NO_WINDOW flag and unify error messages to English

- Add CREATE_NO_WINDOW flag to wsl.exe command to prevent console window flash
- Standardize error messages from Chinese to English for consistency

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-13 12:11:54 +08:00
Dex Miller f9d80b8dc3 feat(stream-check): enhance health check with configurable prompt and CLI-compatible requests (#623)
- Add configurable test prompt field to StreamCheckConfig
- Implement Claude CLI-compatible request format with proper headers:
  - Authorization + x-api-key dual auth
  - anthropic-beta, anthropic-version headers
  - x-stainless-* SDK headers with dynamic OS/arch detection
  - URL with ?beta=true parameter
- Implement Codex CLI-compatible Responses API format:
  - /v1/responses endpoint
  - input array format with reasoning effort support
  - codex_cli_rs user-agent and originator headers
- Add dynamic OS name and CPU architecture detection
- Internationalize error messages (Chinese -> English)
- Add test prompt Textarea UI component with i18n support
- Remove obsolete testPromptDesc translation key
2026-01-13 11:36:19 +08:00
Dex Miller 8b92982112 Feature/global proxy (#596)
* refactor(proxy): simplify logging for better readability

- Delete 17 verbose debug logs from handlers, streaming, and response_processor
- Convert excessive INFO logs to DEBUG level for internal processing details
- Add 2 critical INFO logs in forwarder.rs for failover scenarios:
  - Log when switching to next provider after failure
  - Log when all providers have been exhausted
- Fix clippy uninlined_format_args warning

This reduces log noise while maintaining visibility into key user-facing decisions.

* fix: replace unsafe unwrap() calls with proper error handling

- database/dao/mcp.rs: Use map_err for serde_json serialization
- database/dao/providers.rs: Use map_err for settings_config and meta serialization
- commands/misc.rs: Use expect() for compile-time regex pattern
- services/prompt.rs: Use unwrap_or_default() for SystemTime
- deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks

Reduces potential panic points from 26 to 1 (static regex init, safe).

* refactor(proxy): simplify verbose logging output

- Remove response JSON full output logging in response_processor
- Remove per-request INFO logs in provider_router (failover status, provider selection)
- Change model mapping log from INFO to DEBUG
- Change usage logging failure from INFO to WARN
- Remove redundant debug logs for circuit breaker operations

Reduces log noise significantly while preserving important warnings and errors.

* feat(proxy): add structured log codes for i18n support

Add error code system to proxy module logs for multi-language support:

- CB-001~006: Circuit breaker state transitions and triggers
- SRV-001~004: Proxy server lifecycle events
- FWD-001~002: Request forwarding and failover
- FO-001~005: Failover switch operations
- USG-001~002: Usage logging errors

Log format: [CODE] Chinese message
Frontend/log tools can map codes to any language.

New file: src/proxy/log_codes.rs - centralized code definitions

* chore: bump version to 3.9.1

* style: format code with prettier and rustfmt

* fix(ui): allow number inputs to be fully cleared before saving

- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel

* feat(pricing): support @ separator in model name matching

- Refactor model name cleaning into chained method calls
- Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low)
- Add test case for @ separator matching

* feat(proxy): add global proxy settings support

Add ability to configure a global HTTP/HTTPS proxy for all outbound
requests including provider API calls, speed tests, and stream checks.

* fix(proxy): improve validation and error handling in proxy config panels

- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation

* fix(proxy): harden error handling and input validation

- Handle RwLock poisoning in settings.rs with unwrap_or_else
- Add fallback for dirs::home_dir() in config modules
- Normalize localhost to 127.0.0.1 in ProxyPanel
- Format IPv6 addresses with brackets for valid URLs
- Strict port validation with pure digit regex
- Treat NaN as validation failure in config panels
- Log warning on cost_multiplier parse failure
- Align timeoutSeconds range to [0, 300] across all panels

* feat(proxy): add local proxy auto-scan and fix hot-reload

- Add scan_local_proxies command to detect common proxy ports
- Fix SkillService not using updated proxy after hot-reload
- Move global proxy settings to advanced tab
- Add error handling for scan failures

* fix(proxy): allow localhost input in proxy address field

* fix(proxy): restore request timeout and fix proxy hot-reload issues

- Add URL scheme validation in build_client (http/https/socks5/socks5h)
- Restore per-request timeout for speedtest, stream_check, usage_script, forwarder
- Fix set_global_proxy_url to validate before persisting to DB
- Mask proxy credentials in all log outputs
- Fix forwarder hot-reload by fetching client on each request

* style: format code with prettier

* fix(proxy): improve global proxy stability and error handling

- Fix RwLock silent failures with explicit error propagation
- Handle init() duplicate calls gracefully with warning log
- Align fallback client config with build_client settings
- Make scan_local_proxies async to avoid UI blocking
- Add mixed mode support for Clash 7890 port (http+socks5)
- Use multiple test targets for better proxy connectivity test
- Clear invalid proxy config on init failure
- Restore timeout constraints in usage_script
- Fix mask_url output for URLs without port
- Add structured error codes [GP-001 to GP-009]

* feat(proxy): add username/password authentication support

- Add separate username and password input fields
- Implement password visibility toggle with eye icon
- Add clear button to reset all proxy fields
- Auto-extract auth info from saved URL and merge on save
- Update i18n translations (zh/en/ja)

* fix(proxy): fix double encoding issue in proxy auth and add debug logs

- Remove encodeURIComponent in mergeAuth() since URL object's
  username/password setters already do percent-encoding automatically
- Add GP-010 debug log for database read operations
- Add GP-011 debug log to track incoming URL info (length, has_auth)
- Fix username.trim() in fallback branch for consistent behavior
2026-01-13 10:55:53 +08:00
YoVinchen 4bbd63bf2b feat(templates): add Claude Code Templates marketplace
- Add template repository management and component discovery
- Implement template installation for agents, commands, hooks, MCPs, skills, settings
- Support multi-app installation (Claude/Codex/Gemini)
- Add frontend components for browsing and installing templates
- Include i18n translations for zh/en/ja
2025-12-19 09:42:20 +08:00
63 changed files with 9018 additions and 318 deletions
+1
View File
@@ -727,6 +727,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
+1
View File
@@ -61,6 +61,7 @@ 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"
+247
View File
@@ -0,0 +1,247 @@
//! 全局出站代理相关命令
//!
//! 提供获取、设置和测试全局代理的 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()
}
+135 -12
View File
@@ -5,6 +5,7 @@ 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;
@@ -91,15 +92,14 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new();
// 用于获取远程版本的 client
let client = reqwest::Client::builder()
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| e.to_string())?;
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
for tool in tools {
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = {
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
try_get_version_wsl(tool, &distro)
} else {
// 先尝试直接执行
let direct_result = try_get_version(tool);
@@ -187,7 +187,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("未安装或无法执行".to_string()))
(None, Some("not installed or not executable".to_string()))
} else {
(Some(extract_version(raw)), None)
}
@@ -196,7 +196,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
(
None,
Some(if err.is_empty() {
"未安装或无法执行".to_string()
"not installed or not executable".to_string()
} else {
err
}),
@@ -207,6 +207,88 @@ 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;
@@ -302,7 +384,49 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
}
}
(None, Some("未安装或无法执行".to_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
}
/// 打开指定提供商的终端
@@ -408,7 +532,7 @@ fn launch_terminal_with_env(
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file, &config_path_escaped)?;
return Ok(());
Ok(())
}
#[cfg(target_os = "linux")]
@@ -459,8 +583,7 @@ fn escape_shell_path(path: &std::path::Path) -> String {
/// 生成 bash 包装脚本,用于清理临时文件
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
format!(
"bash -c 'trap \"rm -f \\\"{}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{}\"; claude --settings \"{}\"; exec bash --norc --noprofile'",
config_path, escaped_path, escaped_path
"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'"
)
}
+4
View File
@@ -4,6 +4,7 @@ mod config;
mod deeplink;
mod env;
mod failover;
mod global_proxy;
mod import_export;
mod mcp;
mod misc;
@@ -14,12 +15,14 @@ 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::*;
@@ -30,4 +33,5 @@ pub use proxy::*;
pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use template::*;
pub use usage::*;
+21
View File
@@ -59,3 +59,24 @@ 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)
}
+278
View File
@@ -0,0 +1,278 @@
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}"))?
}
+1
View File
@@ -10,6 +10,7 @@ pub mod proxy;
pub mod settings;
pub mod skills;
pub mod stream_check;
pub mod template;
pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
+58
View File
@@ -63,6 +63,41 @@ 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 替代)---
/// 获取指定应用的代理接管状态
@@ -128,4 +163,27 @@ 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)
}
}
+595
View File
@@ -0,0 +1,595 @@
//! 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(&params_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(&params_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(&params_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(&params_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)
}
}
+181
View File
@@ -302,6 +302,90 @@ 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(())
}
@@ -346,6 +430,12 @@ impl Database {
Self::migrate_v2_to_v3(conn)?;
Self::set_user_version(conn, 3)?;
}
// v3 -> v4: Claude Code Templates 市场功能(暂未启用)
// 3 => {
// log::info!("迁移数据库从 v3 到 v4Claude Code Templates 市场功能)");
// Self::migrate_v3_to_v4(conn)?;
// Self::set_user_version(conn, 4)?;
// }
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -786,6 +876,97 @@ 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 主键结构,
+88 -8
View File
@@ -518,8 +518,7 @@ fn model_pricing_is_seeded_on_init() {
assert!(
count > 0,
"模型定价数据应该在初始化时自动填充,实际数量: {}",
count
"模型定价数据应该在初始化时自动填充,实际数量: {count}"
);
// 验证包含 Claude 模型
@@ -532,8 +531,7 @@ fn model_pricing_is_seeded_on_init() {
.expect("check claude");
assert!(
claude_count > 0,
"应该包含 Claude 模型定价,实际数量: {}",
claude_count
"应该包含 Claude 模型定价,实际数量: {claude_count}"
);
// 验证包含 GPT 模型
@@ -546,8 +544,7 @@ fn model_pricing_is_seeded_on_init() {
.expect("check gpt");
assert!(
gpt_count > 0,
"应该包含 GPT 模型定价,实际数量: {}",
gpt_count
"应该包含 GPT 模型定价,实际数量: {gpt_count}"
);
// 验证包含 Gemini 模型
@@ -560,7 +557,90 @@ 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 个外键约束");
}
+2 -4
View File
@@ -365,8 +365,7 @@ 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={}&description=desc&enabled=true",
content_b64
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={content_b64}&description=desc&enabled=true"
);
let request = parse_deeplink_url(&url).unwrap();
@@ -383,8 +382,7 @@ 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={}&enabled=true",
config_b64
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={config_b64}&enabled=true"
);
let request = parse_deeplink_url(&url).unwrap();
+55
View File
@@ -644,6 +644,37 @@ 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 {
@@ -703,6 +734,8 @@ 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,
@@ -841,6 +874,28 @@ pub fn run() {
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
+5 -1
View File
@@ -319,7 +319,11 @@ impl CircuitBreaker {
}
}
fn release_half_open_permit(&self) {
/// 仅释放 HalfOpen permit,不影响健康统计
///
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub fn release_half_open_permit(&self) {
let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop {
if current == 0 {
+231 -53
View File
@@ -7,15 +7,15 @@ use super::{
error::*,
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter},
types::ProxyStatus,
providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
types::{ProxyStatus, RectifierConfig},
ProxyError,
};
use crate::{app_config::AppType, provider::Provider};
use reqwest::{Client, Response};
use reqwest::Response;
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers
@@ -81,8 +81,6 @@ pub struct ForwardError {
}
pub struct RequestForwarder {
client: Option<Client>,
client_init_error: Option<String>,
/// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>,
@@ -93,6 +91,10 @@ 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 {
@@ -107,52 +109,17 @@ 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),
}
}
@@ -168,7 +135,7 @@ impl RequestForwarder {
&self,
app_type: &AppType,
endpoint: &str,
body: Value,
mut body: Value,
headers: axum::http::HeaderMap,
providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> {
@@ -187,6 +154,9 @@ 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;
@@ -281,6 +251,205 @@ 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
@@ -416,16 +585,17 @@ impl RequestForwarder {
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
// 构建请求
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()),
)
})?;
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
let client = super::http_client::get();
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
@@ -541,3 +711,11 @@ impl RequestForwarder {
}
}
}
/// 从 ProxyError 中提取错误消息
fn extract_error_message(error: &ProxyError) -> Option<String> {
match error {
ProxyError::UpstreamError { body, .. } => body.clone(),
_ => Some(error.to_string()),
}
}
+11 -1
View File
@@ -5,7 +5,10 @@
use crate::app_config::AppType;
use crate::provider::Provider;
use crate::proxy::{
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
extract_session_id,
forwarder::RequestForwarder,
server::ProxyState,
types::{AppProxyConfig, RectifierConfig},
ProxyError,
};
use axum::http::HeaderMap;
@@ -54,6 +57,8 @@ pub struct RequestContext {
pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
/// 整流器配置
pub rectifier_config: RectifierConfig,
}
impl RequestContext {
@@ -86,6 +91,9 @@ 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();
@@ -147,6 +155,7 @@ impl RequestContext {
app_type_str,
app_type,
session_id,
rectifier_config,
})
}
@@ -206,6 +215,7 @@ impl RequestContext {
self.current_provider_id.clone(),
first_byte_timeout,
idle_timeout,
self.rectifier_config.clone(),
)
}
+301
View File
@@ -0,0 +1,301 @@
//! 全局 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` - 代理 URLNone 或空字符串表示直连
///
/// # 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` - 代理 URLNone 或空字符串表示直连
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` - 新的代理 URLNone 或空字符串表示直连
#[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");
}
}
+2
View File
@@ -12,6 +12,7 @@ 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;
@@ -20,6 +21,7 @@ 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;
+69
View File
@@ -151,6 +151,24 @@ 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();
}
/// 更新所有熔断器的配置(热更新)
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
let breakers = self.circuit_breakers.read().await;
@@ -325,4 +343,55 @@ 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);
}
}
+421
View File
@@ -0,0 +1,421 @@
//! 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
}
}
+64
View File
@@ -191,3 +191,67 @@ 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);
}
}
+7
View File
@@ -8,6 +8,7 @@ pub mod proxy;
pub mod skill;
pub mod speedtest;
pub mod stream_check;
pub mod template;
pub mod usage_stats;
pub use config::ConfigService;
@@ -19,6 +20,12 @@ 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,
+4 -12
View File
@@ -7,7 +7,6 @@
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
@@ -143,9 +142,7 @@ pub struct SkillMetadata {
// ========== SkillService ==========
pub struct SkillService {
http_client: Client,
}
pub struct SkillService;
impl Default for SkillService {
fn default() -> Self {
@@ -155,13 +152,7 @@ impl Default for SkillService {
impl SkillService {
pub fn new() -> Self {
Self {
http_client: Client::builder()
.user_agent("cc-switch")
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
Self
}
// ========== 路径管理 ==========
@@ -863,7 +854,8 @@ impl SkillService {
/// 下载并解压 ZIP
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
let response = self.http_client.get(url).send().await?;
let client = crate::proxy::http_client::get();
let response = 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(
+13 -17
View File
@@ -1,7 +1,7 @@
use futures::future::join_all;
use reqwest::{Client, Url};
use serde::Serialize;
use std::time::{Duration, Instant};
use std::time::Instant;
use crate::error::AppError;
@@ -65,17 +65,21 @@ impl SpeedtestService {
}
let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?;
let (client, request_timeout) = 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()).send().await;
let _ = client
.get(parsed_url.clone())
.timeout(request_timeout)
.send()
.await;
// 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now();
let latency = match client.get(parsed_url).send().await {
let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
Ok(resp) => EndpointLatency {
url: trimmed,
latency: Some(start.elapsed().as_millis()),
@@ -112,19 +116,11 @@ impl SpeedtestService {
Ok(results.into_iter().flatten().collect::<Vec<_>>())
}
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 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 sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
+177 -46
View File
@@ -7,7 +7,7 @@ use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::{Duration, Instant};
use std::time::Instant;
use crate::app_config::AppType;
use crate::error::AppError;
@@ -36,6 +36,13 @@ 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 {
@@ -47,6 +54,7 @@ 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(),
}
}
}
@@ -110,7 +118,7 @@ impl StreamCheckService {
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: "检查失败".to_string(),
message: "Check failed".to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
@@ -130,29 +138,52 @@ impl StreamCheckService {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
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}")))?;
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
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).await
Self::check_claude_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_codex_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_gemini_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
};
@@ -166,7 +197,7 @@ impl StreamCheckService {
Ok(StreamCheckResult {
status: health_status,
success: true,
message: "检查成功".to_string(),
message: "Check succeeded".to_string(),
response_time_ms: Some(response_time),
http_status: Some(status_code),
model_used: model,
@@ -188,31 +219,69 @@ 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")
format!("{base}/messages?beta=true")
} else {
format!("{base}/v1/messages")
format!("{base}/v1/messages?beta=true")
};
let body = json!({
"model": model,
"max_tokens": 1,
"messages": [{ "role": "user", "content": "hi" }],
"messages": [{ "role": "user", "content": test_prompt }],
"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("Content-Type", "application/json")
.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)
.json(&body)
.send()
.await
@@ -230,51 +299,64 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".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}/chat/completions")
format!("{base}/responses")
} else {
format!("{base}/v1/chat/completions")
format!("{base}/v1/responses")
};
// 解析模型名和推理等级 (支持 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,
"messages": [
{ "role": "system", "content": "" },
{ "role": "assistant", "content": "" },
{ "role": "user", "content": "hi" }
],
"max_tokens": 1,
"temperature": 0,
"input": [{ "role": "user", "content": test_prompt }],
"stream": true
});
// 如果是推理模型,添加 reasoning_effort
if let Some(effort) = reasoning_effort {
body["reasoning_effort"] = json!(effort);
body["reasoning"] = json!({ "effort": effort });
}
// 严格按照 Codex CLI 请求格式设置 headers
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.header("authorization", format!("Bearer {}", auth.api_key))
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header(
"user-agent",
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
)
.header("originator", "codex_cli_rs")
.timeout(timeout)
.json(&body)
.send()
.await
@@ -291,10 +373,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
@@ -304,13 +386,15 @@ 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": "hi" }],
"messages": [{ "role": "user", "content": test_prompt }],
"max_tokens": 1,
"temperature": 0,
"stream": true
@@ -320,6 +404,7 @@ impl StreamCheckService {
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.timeout(timeout)
.json(&body)
.send()
.await
@@ -336,10 +421,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
@@ -354,7 +439,6 @@ 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();
@@ -367,17 +451,14 @@ impl StreamCheckService {
fn should_retry(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("timeout")
|| lower.contains("abort")
|| lower.contains("中断")
|| lower.contains("超时")
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
}
fn map_request_error(e: reqwest::Error) -> AppError {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
AppError::Message("Request timeout".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
AppError::Message(format!("Connection failed: {e}"))
} else {
AppError::Message(e.to_string())
}
@@ -424,6 +505,26 @@ 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)]
@@ -448,9 +549,10 @@ mod tests {
#[test]
fn test_should_retry() {
assert!(StreamCheckService::should_retry("请求超时"));
assert!(StreamCheckService::should_retry("request timeout"));
assert!(!StreamCheckService::should_retry("API Key 无效"));
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"));
}
#[test]
@@ -478,4 +580,33 @@ 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");
}
}
@@ -0,0 +1,267 @@
//! 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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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()
}
}
@@ -0,0 +1,299 @@
//! 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, &current)?;
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, &current)?;
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()
}
}
@@ -0,0 +1,239 @@
//! 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, &current)?;
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, &current)?;
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()
}
}
@@ -0,0 +1,107 @@
//! 应用适配器模块
//!
//! 负责将 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()),
}
}
+746
View File
@@ -0,0 +1,746 @@
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,
})
}
}
@@ -0,0 +1,525 @@
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)
}
}
+357
View File
@@ -0,0 +1,357 @@
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 失败")
}
}
+239
View File
@@ -0,0 +1,239 @@
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);
}
}
+8 -18
View File
@@ -1,8 +1,6 @@
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;
@@ -215,18 +213,10 @@ struct RequestConfig {
/// 发送 HTTP 请求
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
// 约束超时范围,防止异常配置导致长时间阻塞
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 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
// 严格校验 HTTP 方法,非法值不回退为 GET
let method: reqwest::Method = config.method.parse().map_err(|_| {
@@ -237,7 +227,9 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
)
})?;
let mut req = client.request(method.clone(), &config.url);
let mut req = client
.request(method.clone(), &config.url)
.timeout(request_timeout);
// 添加请求头
for (k, v) in &config.headers {
@@ -864,9 +856,7 @@ mod tests {
} else {
assert!(
result.is_err(),
"应该不匹配的URL被允许: base_url={}, request_url={}",
base_url,
request_url
"应该不匹配的URL被允许: base_url={base_url}, request_url={request_url}"
);
}
}
+18 -1
View File
@@ -13,6 +13,7 @@ import {
Wrench,
Server,
RefreshCw,
Package,
Search,
Download,
} from "lucide-react";
@@ -48,6 +49,7 @@ 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";
@@ -58,6 +60,7 @@ type View =
| "skills"
| "skillsDiscovery"
| "mcp"
| "templates"
| "agents"
| "universal";
@@ -472,6 +475,8 @@ 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">
@@ -502,7 +507,9 @@ function App() {
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onOpenTerminal={activeApp === "claude" ? handleOpenTerminal : undefined}
onOpenTerminal={
activeApp === "claude" ? handleOpenTerminal : undefined
}
onCreate={() => setIsAddOpen(true)}
/>
</motion.div>
@@ -610,6 +617,7 @@ 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: "统一供应商",
@@ -788,6 +796,15 @@ 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
+8 -2
View File
@@ -301,7 +301,11 @@ export function ProviderCard({
<div
className="relative flex items-center ml-auto min-w-0 gap-3"
style={{ "--actions-width": `${actionsWidth || 320}px` } as React.CSSProperties}
style={
{
"--actions-width": `${actionsWidth || 320}px`,
} as React.CSSProperties
}
>
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<div className="ml-auto">
@@ -365,7 +369,9 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onOpenTerminal={onOpenTerminal ? () => onOpenTerminal(provider) : undefined}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
+5 -9
View File
@@ -106,13 +106,11 @@ export function ProxyPanel() {
// 校验地址格式(简单的 IP 地址或 localhost 校验)
const addressTrimmed = listenAddress.trim();
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
// 规范化 localhost 为 127.0.0.1
const normalizedAddress =
addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed;
const isValidAddress =
normalizedAddress === "0.0.0.0" ||
(ipv4Regex.test(normalizedAddress) &&
normalizedAddress.split(".").every((n) => {
addressTrimmed === "localhost" ||
addressTrimmed === "0.0.0.0" ||
(ipv4Regex.test(addressTrimmed) &&
addressTrimmed.split(".").every((n) => {
const num = parseInt(n);
return num >= 0 && num <= 255;
}));
@@ -148,11 +146,9 @@ export function ProxyPanel() {
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress: normalizedAddress,
listenAddress: addressTrimmed,
listenPort: port,
});
// 同步更新本地状态为规范化后的值
setListenAddress(normalizedAddress);
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
@@ -0,0 +1,275 @@
/**
* 全局出站代理设置组件
*
* 提供配置全局代理的输入界面,支持用户名密码认证。
*/
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>
);
}
@@ -0,0 +1,75 @@
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>
);
}
+48
View File
@@ -9,6 +9,8 @@ import {
Database,
Server,
ChevronDown,
Zap,
Globe,
} from "lucide-react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { toast } from "sonner";
@@ -34,12 +36,14 @@ 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";
@@ -495,6 +499,28 @@ 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"
@@ -526,6 +552,28 @@ 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">
+163
View File
@@ -0,0 +1,163 @@
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>
);
}
+399
View File
@@ -0,0 +1,399 @@
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}
/>
)}
</>
);
}
@@ -0,0 +1,75 @@
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>
);
}
+165
View File
@@ -0,0 +1,165 @@
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>
);
}
@@ -0,0 +1,314 @@
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>
);
}
+294
View File
@@ -0,0 +1,294 @@
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>
);
}
+334
View File
@@ -0,0 +1,334 @@
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>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { TemplatesPage } from "./TemplatesPage";
export { ComponentCard } from "./ComponentCard";
export { ComponentDetail } from "./ComponentDetail";
export { CategoryFilter } from "./CategoryFilter";
export { RepoManager } from "./RepoManager";
+1 -1
View File
@@ -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-600 dark:hover:bg-red-700",
"bg-red-500 text-white hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600",
// 轮廓按钮
outline:
"border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800",
@@ -2,6 +2,7 @@ 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";
@@ -25,6 +26,7 @@ export function ModelTestConfigPanel() {
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low",
geminiModel: "gemini-3-pro-preview",
testPrompt: "Who are you?",
});
useEffect(() => {
@@ -43,6 +45,7 @@ export function ModelTestConfigPanel() {
claudeModel: data.claudeModel,
codexModel: data.codexModel,
geminiModel: data.geminiModel,
testPrompt: data.testPrompt || "Who are you?",
});
} catch (e) {
setError(String(e));
@@ -66,6 +69,7 @@ export function ModelTestConfigPanel() {
claudeModel: config.claudeModel,
codexModel: config.codexModel,
geminiModel: config.geminiModel,
testPrompt: config.testPrompt || "Who are you?",
};
await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), {
@@ -189,6 +193,21 @@ export function ModelTestConfigPanel() {
/>
</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">
@@ -202,6 +202,7 @@ export function PricingConfigPanel() {
{editingModel && (
<PricingEditModal
open={!!editingModel}
model={editingModel}
isNew={isAddingNew}
onClose={() => {
+127 -127
View File
@@ -1,13 +1,8 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Save, Plus } from "lucide-react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -15,12 +10,14 @@ import { useUpdateModelPricing } from "@/lib/query/usage";
import type { ModelPricing } from "@/types/usage";
interface PricingEditModalProps {
open: boolean;
model: ModelPricing;
isNew?: boolean;
onClose: () => void;
}
export function PricingEditModal({
open,
model,
isNew = false,
onClose,
@@ -86,139 +83,142 @@ export function PricingEditModal({
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isNew
? t("usage.addPricing", "新增定价")
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isNew && (
<div className="space-y-2">
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
<Input
id="modelId"
value={formData.modelId}
onChange={(e) =>
setFormData({ ...formData, modelId: e.target.value })
}
placeholder={t("usage.modelIdPlaceholder", {
defaultValue: "例如: claude-3-5-sonnet-20241022",
})}
required
/>
</div>
<FullScreenPanel
isOpen={open}
title={
isNew
? t("usage.addPricing", "新增定价")
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`
}
onClose={onClose}
footer={
<Button
type="submit"
form="pricing-form"
disabled={updatePricing.isPending}
>
{isNew ? (
<Plus className="h-4 w-4 mr-2" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
{updatePricing.isPending
? t("common.saving", "保存中...")
: isNew
? t("common.add", "新增")
: t("common.save", "保存")}
</Button>
}
>
<form id="pricing-form" onSubmit={handleSubmit} className="space-y-6">
{isNew && (
<div className="space-y-2">
<Label htmlFor="displayName">
{t("usage.displayName", "显示名称")}
</Label>
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
<Input
id="displayName"
value={formData.displayName}
id="modelId"
value={formData.modelId}
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
setFormData({ ...formData, modelId: e.target.value })
}
placeholder={t("usage.displayNamePlaceholder", {
defaultValue: "例如: Claude 3.5 Sonnet",
placeholder={t("usage.modelIdPlaceholder", {
defaultValue: "例如: claude-3-5-sonnet-20241022",
})}
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="inputCost">
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
</Label>
<Input
id="inputCost"
type="number"
step="0.01"
min="0"
value={formData.inputCost}
onChange={(e) =>
setFormData({ ...formData, inputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="displayName">
{t("usage.displayName", "显示名称")}
</Label>
<Input
id="displayName"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
}
placeholder={t("usage.displayNamePlaceholder", {
defaultValue: "例如: Claude 3.5 Sonnet",
})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="outputCost">
{t("usage.outputCostPerMillion", "输成本 (每百万 tokens, USD)")}
</Label>
<Input
id="outputCost"
type="number"
step="0.01"
min="0"
value={formData.outputCost}
onChange={(e) =>
setFormData({ ...formData, outputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="inputCost">
{t("usage.inputCostPerMillion", "输成本 (每百万 tokens, USD)")}
</Label>
<Input
id="inputCost"
type="number"
step="0.01"
min="0"
value={formData.inputCost}
onChange={(e) =>
setFormData({ ...formData, inputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="cacheReadCost">
{t(
"usage.cacheReadCostPerMillion",
"缓存读取成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheReadCost"
type="number"
step="0.01"
min="0"
value={formData.cacheReadCost}
onChange={(e) =>
setFormData({ ...formData, cacheReadCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="outputCost">
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
</Label>
<Input
id="outputCost"
type="number"
step="0.01"
min="0"
value={formData.outputCost}
onChange={(e) =>
setFormData({ ...formData, outputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="cacheCreationCost">
{t(
"usage.cacheCreationCostPerMillion",
"缓存写入成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheCreationCost"
type="number"
step="0.01"
min="0"
value={formData.cacheCreationCost}
onChange={(e) =>
setFormData({ ...formData, cacheCreationCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="cacheReadCost">
{t(
"usage.cacheReadCostPerMillion",
"缓存读取成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheReadCost"
type="number"
step="0.01"
min="0"
value={formData.cacheReadCost}
onChange={(e) =>
setFormData({ ...formData, cacheReadCost: e.target.value })
}
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel", "取消")}
</Button>
<Button type="submit" disabled={updatePricing.isPending}>
{updatePricing.isPending
? t("common.saving", "保存中...")
: isNew
? t("common.add", "新增")
: t("common.save", "保存")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="space-y-2">
<Label htmlFor="cacheCreationCost">
{t(
"usage.cacheCreationCostPerMillion",
"缓存写入成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheCreationCost"
type="number"
step="0.01"
min="0"
value={formData.cacheCreationCost}
onChange={(e) =>
setFormData({ ...formData, cacheCreationCost: e.target.value })
}
required
/>
</div>
</form>
</FullScreenPanel>
);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* 全局出站代理 React Hooks
*
* 提供获取、设置和测试全局代理的 React Query hooks。
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
getGlobalProxyUrl,
setGlobalProxyUrl,
testProxyUrl,
getUpstreamProxyStatus,
scanLocalProxies,
type ProxyTestResult,
type UpstreamProxyStatus,
type DetectedProxy,
} from "@/lib/api/globalProxy";
/**
* 获取全局代理 URL
*/
export function useGlobalProxyUrl() {
return useQuery({
queryKey: ["globalProxyUrl"],
queryFn: getGlobalProxyUrl,
staleTime: 30 * 1000, // 30秒内不重新获取,避免展开时闪烁
});
}
/**
* 设置全局代理 URL
*/
export function useSetGlobalProxyUrl() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: setGlobalProxyUrl,
onSuccess: () => {
toast.success(t("settings.globalProxy.saved"));
queryClient.invalidateQueries({ queryKey: ["globalProxyUrl"] });
queryClient.invalidateQueries({ queryKey: ["upstreamProxyStatus"] });
},
onError: (error: unknown) => {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
toast.error(t("settings.globalProxy.saveFailed", { error: message }));
},
});
}
/**
* 测试代理连接
*/
export function useTestProxy() {
const { t } = useTranslation();
return useMutation({
mutationFn: testProxyUrl,
onSuccess: (result: ProxyTestResult) => {
if (result.success) {
toast.success(
t("settings.globalProxy.testSuccess", { latency: result.latencyMs }),
);
} else {
toast.error(
t("settings.globalProxy.testFailed", { error: result.error }),
);
}
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
/**
* 获取当前出站代理状态
*/
export function useUpstreamProxyStatus() {
return useQuery<UpstreamProxyStatus>({
queryKey: ["upstreamProxyStatus"],
queryFn: getUpstreamProxyStatus,
});
}
/**
* 扫描本地代理
*/
export function useScanProxies() {
const { t } = useTranslation();
return useMutation({
mutationFn: scanLocalProxies,
onError: (error: Error) => {
toast.error(
t("settings.globalProxy.scanFailed", { error: error.message }),
);
},
});
}
export type { DetectedProxy };
+114 -2
View File
@@ -183,9 +183,23 @@
"title": "Cost Pricing",
"description": "Manage token pricing rules for each model"
},
"globalProxy": {
"title": "Global Outbound Proxy",
"description": "Configure proxy for CC Switch to access external APIs"
},
"data": {
"title": "Data Management",
"description": "Import/export configurations and backup/restore"
},
"rectifier": {
"title": "Rectifier",
"description": "Automatically fix API request compatibility issues",
"enabled": "Enable Rectifier",
"enabledDescription": "Master switch, all rectification features will be disabled when turned off",
"requestGroup": "Request Rectification",
"responseGroup": "Response Rectification",
"thinkingSignature": "Thinking Signature Rectification",
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
}
},
"language": "Language",
@@ -271,7 +285,21 @@
"restartLater": "Restart Later",
"restartFailed": "Application restart failed, please manually close and reopen.",
"devModeRestartHint": "Dev Mode: Automatic restart not supported, please manually restart the application.",
"saving": "Saving..."
"saving": "Saving...",
"globalProxy": {
"label": "Global Proxy",
"hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection.",
"username": "Username (optional)",
"password": "Password (optional)",
"test": "Test Connection",
"scan": "Scan Local Proxies",
"clear": "Clear",
"scanFailed": "Scan failed: {{error}}",
"saved": "Proxy settings saved",
"saveFailed": "Save failed: {{error}}",
"testSuccess": "Connected! Latency {{latency}}ms",
"testFailed": "Connection failed: {{error}}"
}
},
"apps": {
"claude": "Claude Code",
@@ -1018,6 +1046,89 @@
"circuitOpen": "Circuit Open",
"consecutiveFailures": "{{count}} consecutive failures"
},
"templates": {
"title": "Template Market",
"search": "Search components...",
"refresh": "Refresh Index",
"refreshing": "Refreshing...",
"types": {
"agent": "Agent",
"command": "Command",
"mcp": "MCP Service",
"setting": "Setting",
"hook": "Hook",
"skill": "Skill"
},
"categories": {
"all": "All",
"security": "Security",
"development": "Development",
"database": "Database",
"web-tools": "Web Tools"
},
"card": {
"install": "Install",
"installed": "Installed",
"uninstall": "Uninstall",
"installing": "Installing..."
},
"detail": {
"description": "Description",
"metadata": "Metadata",
"content": "Content Preview",
"viewOnGithub": "View on GitHub",
"installTo": "Install to",
"type": "Type",
"category": "Category",
"model": "Model",
"tools": "Tools"
},
"repos": {
"title": "Template Repositories",
"add": "Add Repository",
"remove": "Remove",
"enable": "Enable",
"disable": "Disable",
"owner": "Owner",
"name": "Name",
"branch": "Branch"
},
"errors": {
"refreshFailed": "Failed to refresh index",
"installFailed": "Installation failed",
"uninstallFailed": "Uninstallation failed",
"loadFailed": "Failed to load components"
},
"empty": {
"noComponents": "No components",
"noResults": "No matching components found"
},
"notifications": {
"installSuccess": "Component installed successfully",
"uninstallSuccess": "Component uninstalled successfully",
"refreshSuccess": "Index refreshed successfully"
},
"type": {
"bundle": "Bundle",
"agent": "Agent",
"command": "Command",
"mcp": "MCP",
"setting": "Setting",
"hook": "Hook",
"skill": "Skill"
},
"bundle": {
"empty": "No bundles",
"emptyDescription": "Add a template repository with components.json",
"installTo": "Install to:",
"componentCount": "{{count}} components",
"install": "Install Bundle",
"noMatch": "No matching components found",
"installSuccess": "Installed {{count}} components",
"partialFail": "{{count}} components failed to install",
"installFailed": "Failed to install bundle"
}
},
"proxy": {
"panel": {
"serviceAddress": "Service Address",
@@ -1174,7 +1285,8 @@
"checkParams": "Check Parameters",
"timeout": "Timeout (seconds)",
"maxRetries": "Max Retries",
"degradedThreshold": "Degraded Threshold (ms)"
"degradedThreshold": "Degraded Threshold (ms)",
"testPrompt": "Test Prompt"
},
"proxyConfig": {
"proxyEnabled": "Proxy Enabled",
+114 -2
View File
@@ -183,9 +183,23 @@
"title": "コスト計算",
"description": "各モデルのトークン料金ルールを管理"
},
"globalProxy": {
"title": "グローバル送信プロキシ",
"description": "CC Switch が外部 API にアクセスする際のプロキシを設定"
},
"data": {
"title": "データ管理",
"description": "設定のインポート/エクスポートとバックアップ/復元"
},
"rectifier": {
"title": "整流器",
"description": "API リクエストの互換性問題を自動修正",
"enabled": "整流器を有効化",
"enabledDescription": "マスタースイッチ、オフにするとすべての整流機能が無効になります",
"requestGroup": "リクエスト整流",
"responseGroup": "レスポンス整流",
"thinkingSignature": "Thinking 署名整流",
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
}
},
"language": "言語",
@@ -271,7 +285,21 @@
"restartLater": "後で再起動",
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
"saving": "保存中..."
"saving": "保存中...",
"globalProxy": {
"label": "グローバルプロキシ",
"hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。",
"username": "ユーザー名(任意)",
"password": "パスワード(任意)",
"test": "接続テスト",
"scan": "ローカルプロキシをスキャン",
"clear": "クリア",
"scanFailed": "スキャンに失敗しました: {{error}}",
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}",
"testSuccess": "接続成功!遅延 {{latency}}ms",
"testFailed": "接続に失敗しました: {{error}}"
}
},
"apps": {
"claude": "Claude Code",
@@ -1011,6 +1039,89 @@
"agents": {
"title": "エージェント"
},
"templates": {
"title": "テンプレートマーケット",
"search": "コンポーネントを検索...",
"refresh": "インデックスを更新",
"refreshing": "更新中...",
"types": {
"agent": "エージェント",
"command": "コマンド",
"mcp": "MCP サービス",
"setting": "設定",
"hook": "フック",
"skill": "スキル"
},
"categories": {
"all": "すべて",
"security": "セキュリティ",
"development": "開発",
"database": "データベース",
"web-tools": "ウェブツール"
},
"card": {
"install": "インストール",
"installed": "インストール済み",
"uninstall": "アンインストール",
"installing": "インストール中..."
},
"detail": {
"description": "説明",
"metadata": "メタデータ",
"content": "コンテンツプレビュー",
"viewOnGithub": "GitHub で見る",
"installTo": "インストール先",
"type": "タイプ",
"category": "カテゴリ",
"model": "モデル",
"tools": "ツール"
},
"repos": {
"title": "テンプレートリポジトリ",
"add": "リポジトリを追加",
"remove": "削除",
"enable": "有効化",
"disable": "無効化",
"owner": "オーナー",
"name": "名前",
"branch": "ブランチ"
},
"errors": {
"refreshFailed": "インデックスの更新に失敗しました",
"installFailed": "インストールに失敗しました",
"uninstallFailed": "アンインストールに失敗しました",
"loadFailed": "コンポーネントの読み込みに失敗しました"
},
"empty": {
"noComponents": "コンポーネントなし",
"noResults": "一致するコンポーネントが見つかりません"
},
"notifications": {
"installSuccess": "コンポーネントのインストールに成功しました",
"uninstallSuccess": "コンポーネントのアンインストールに成功しました",
"refreshSuccess": "インデックスの更新に成功しました"
},
"type": {
"bundle": "バンドル",
"agent": "エージェント",
"command": "コマンド",
"mcp": "MCP",
"setting": "設定",
"hook": "フック",
"skill": "スキル"
},
"bundle": {
"empty": "バンドルなし",
"emptyDescription": "components.json を含むテンプレートリポジトリを追加してください",
"installTo": "インストール先:",
"componentCount": "{{count}} 個のコンポーネント",
"install": "バンドルをインストール",
"noMatch": "一致するコンポーネントが見つかりません",
"installSuccess": "{{count}} 個のコンポーネントをインストールしました",
"partialFail": "{{count}} 個のコンポーネントのインストールに失敗しました",
"installFailed": "バンドルのインストールに失敗しました"
}
},
"health": {
"operational": "正常",
"degraded": "低下",
@@ -1168,7 +1279,8 @@
"checkParams": "チェックパラメーター",
"timeout": "タイムアウト(秒)",
"maxRetries": "最大リトライ回数",
"degradedThreshold": "劣化しきい値(ミリ秒)"
"degradedThreshold": "劣化しきい値(ミリ秒)",
"testPrompt": "テストプロンプト"
},
"proxyConfig": {
"proxyEnabled": "プロキシ有効",
+114 -2
View File
@@ -183,9 +183,23 @@
"title": "成本定价",
"description": "管理各模型 Token 计费规则"
},
"globalProxy": {
"title": "全局出站代理",
"description": "配置 CC Switch 访问外部 API 时使用的代理"
},
"data": {
"title": "数据管理",
"description": "导入导出配置与备份恢复"
},
"rectifier": {
"title": "整流器",
"description": "自动修复 API 请求中的兼容性问题",
"enabled": "启用整流器",
"enabledDescription": "总开关,关闭后所有整流功能将被禁用",
"requestGroup": "请求整流",
"responseGroup": "响应整流",
"thinkingSignature": "Thinking 签名整流",
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
}
},
"language": "界面语言",
@@ -271,7 +285,21 @@
"restartLater": "稍后重启",
"restartFailed": "应用重启失败,请手动关闭后重新打开。",
"devModeRestartHint": "开发模式下不支持自动重启,请手动重新启动应用。",
"saving": "正在保存..."
"saving": "正在保存...",
"globalProxy": {
"label": "全局代理",
"hint": "代理所有请求(API、Skills 下载等)。留空表示直连。",
"username": "用户名(可选)",
"password": "密码(可选)",
"test": "测试连接",
"scan": "扫描本地代理",
"clear": "清除",
"scanFailed": "扫描失败:{{error}}",
"saved": "代理设置已保存",
"saveFailed": "保存失败:{{error}}",
"testSuccess": "连接成功!延迟 {{latency}}ms",
"testFailed": "连接失败:{{error}}"
}
},
"apps": {
"claude": "Claude Code",
@@ -1018,6 +1046,89 @@
"circuitOpen": "熔断",
"consecutiveFailures": "连续失败 {{count}} 次"
},
"templates": {
"title": "模板市场",
"search": "搜索组件...",
"refresh": "刷新索引",
"refreshing": "刷新中...",
"types": {
"agent": "代理",
"command": "命令",
"mcp": "MCP 服务",
"setting": "设置",
"hook": "钩子",
"skill": "技能"
},
"categories": {
"all": "全部",
"security": "安全",
"development": "开发",
"database": "数据库",
"web-tools": "Web 工具"
},
"card": {
"install": "安装",
"installed": "已安装",
"uninstall": "卸载",
"installing": "安装中..."
},
"detail": {
"description": "描述",
"metadata": "元数据",
"content": "内容预览",
"viewOnGithub": "在 GitHub 上查看",
"installTo": "安装到",
"type": "类型",
"category": "分类",
"model": "模型",
"tools": "工具"
},
"repos": {
"title": "模板仓库",
"add": "添加仓库",
"remove": "删除",
"enable": "启用",
"disable": "禁用",
"owner": "所有者",
"name": "名称",
"branch": "分支"
},
"errors": {
"refreshFailed": "刷新索引失败",
"installFailed": "安装失败",
"uninstallFailed": "卸载失败",
"loadFailed": "加载组件失败"
},
"empty": {
"noComponents": "暂无组件",
"noResults": "未找到匹配的组件"
},
"notifications": {
"installSuccess": "组件安装成功",
"uninstallSuccess": "组件卸载成功",
"refreshSuccess": "索引刷新成功"
},
"type": {
"bundle": "组合",
"agent": "代理",
"command": "命令",
"mcp": "MCP",
"setting": "设置",
"hook": "钩子",
"skill": "技能"
},
"bundle": {
"empty": "暂无组合",
"emptyDescription": "请添加包含 components.json 的模板仓库",
"installTo": "安装到:",
"componentCount": "{{count}} 个组件",
"install": "安装组合",
"noMatch": "未找到匹配的组件",
"installSuccess": "已安装 {{count}} 个组件",
"partialFail": "{{count}} 个组件安装失败",
"installFailed": "安装组合失败"
}
},
"proxy": {
"panel": {
"serviceAddress": "服务地址",
@@ -1174,7 +1285,8 @@
"checkParams": "检查参数",
"timeout": "超时时间(秒)",
"maxRetries": "最大重试次数",
"degradedThreshold": "降级阈值(毫秒)"
"degradedThreshold": "降级阈值(毫秒)",
"testPrompt": "检查提示词"
},
"proxyConfig": {
"proxyEnabled": "代理总开关",
+85
View File
@@ -0,0 +1,85 @@
/**
* 全局出站代理 API
*
* 提供获取、设置和测试全局代理的功能。
*/
import { invoke } from "@tauri-apps/api/core";
/**
* 代理测试结果
*/
export interface ProxyTestResult {
success: boolean;
latencyMs: number;
error: string | null;
}
/**
* 出站代理状态
*/
export interface UpstreamProxyStatus {
enabled: boolean;
proxyUrl: string | null;
}
/**
* 检测到的代理
*/
export interface DetectedProxy {
url: string;
proxyType: string;
port: number;
}
/**
* 获取全局代理 URL
*
* @returns 代理 URLnull 表示未配置(直连)
*/
export async function getGlobalProxyUrl(): Promise<string | null> {
return invoke<string | null>("get_global_proxy_url");
}
/**
* 设置全局代理 URL
*
* @param url - 代理 URL(如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080
* 空字符串表示清除代理(直连)
*/
export async function setGlobalProxyUrl(url: string): Promise<void> {
try {
return await invoke("set_global_proxy_url", { url });
} catch (error) {
// Tauri invoke 错误可能是字符串
throw new Error(typeof error === "string" ? error : String(error));
}
}
/**
* 测试代理连接
*
* @param url - 要测试的代理 URL
* @returns 测试结果,包含是否成功、延迟和错误信息
*/
export async function testProxyUrl(url: string): Promise<ProxyTestResult> {
return invoke<ProxyTestResult>("test_proxy_url", { url });
}
/**
* 获取当前出站代理状态
*
* @returns 代理状态,包含是否启用和代理 URL
*/
export async function getUpstreamProxyStatus(): Promise<UpstreamProxyStatus> {
return invoke<UpstreamProxyStatus>("get_upstream_proxy_status");
}
/**
* 扫描本地代理
*
* @returns 检测到的代理列表
*/
export async function scanLocalProxies(): Promise<DetectedProxy[]> {
return invoke<DetectedProxy[]>("scan_local_proxies");
}
+1
View File
@@ -12,6 +12,7 @@ export interface StreamCheckConfig {
claudeModel: string;
codexModel: string;
geminiModel: string;
testPrompt: string;
}
export interface StreamCheckResult {
+13
View File
@@ -134,4 +134,17 @@ export const settingsApi = {
> {
return await invoke("get_tool_versions");
},
async getRectifierConfig(): Promise<RectifierConfig> {
return await invoke("get_rectifier_config");
},
async setRectifierConfig(config: RectifierConfig): Promise<boolean> {
return await invoke("set_rectifier_config", { config });
},
};
export interface RectifierConfig {
enabled: boolean;
requestThinkingSignature: boolean;
}
+102
View File
@@ -0,0 +1,102 @@
import { invoke } from "@tauri-apps/api/core";
import type {
TemplateRepo,
TemplateComponent,
ComponentDetail,
PaginatedResult,
ComponentFilter,
BatchInstallResult,
InstalledComponent,
ComponentType,
} from "@/types/template";
import type { AppType } from "./config";
export const templateApi = {
// 模板仓库管理
async listTemplateRepos(): Promise<TemplateRepo[]> {
return invoke("list_template_repos");
},
async addTemplateRepo(
owner: string,
name: string,
branch: string,
): Promise<void> {
return invoke("add_template_repo", { owner, name, branch });
},
async removeTemplateRepo(id: number): Promise<void> {
return invoke("remove_template_repo", { id });
},
async toggleTemplateRepo(id: number, enabled: boolean): Promise<void> {
return invoke("toggle_template_repo", { id, enabled });
},
// 模板索引刷新
async refreshTemplateIndex(): Promise<void> {
return invoke("refresh_template_index");
},
// 模板组件查询
async listTemplateComponents(
filter: ComponentFilter,
): Promise<PaginatedResult<TemplateComponent>> {
return invoke("list_template_components", {
componentType: filter.componentType,
category: filter.category,
search: filter.search,
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 20,
appType: filter.appType,
});
},
async getTemplateComponent(id: number): Promise<ComponentDetail> {
return invoke("get_template_component", { id });
},
async getComponentCategories(
componentType?: ComponentType,
): Promise<string[]> {
return invoke("list_template_categories", { componentType });
},
// 组件安装管理
async installTemplateComponent(id: number, appType: AppType): Promise<void> {
return invoke("install_template_component", { id, appType });
},
async uninstallTemplateComponent(
id: number,
appType: AppType,
): Promise<void> {
return invoke("uninstall_template_component", { id, appType });
},
async batchInstallComponents(
ids: number[],
appType: AppType,
): Promise<BatchInstallResult> {
return invoke("batch_install_template_components", { ids, appType });
},
async listInstalledComponents(
appType?: AppType,
componentType?: ComponentType,
): Promise<InstalledComponent[]> {
return invoke("list_installed_components", { appType, componentType });
},
// 组件内容预览
async previewComponentContent(id: number): Promise<string> {
return invoke("preview_component_content", { id });
},
// 市场组合
async listMarketplaceBundles(): Promise<
import("@/types/template").MarketplaceBundle[]
> {
return invoke("list_marketplace_bundles");
},
};
+177
View File
@@ -0,0 +1,177 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { templateApi } from "@/lib/api/template";
import type { ComponentFilter, ComponentType } from "@/types/template";
import type { AppType } from "@/lib/api/config";
// Query keys
export const templateKeys = {
all: ["templates"] as const,
repos: () => [...templateKeys.all, "repos"] as const,
components: (filter: ComponentFilter) =>
[...templateKeys.all, "components", filter] as const,
component: (id: number) => [...templateKeys.all, "component", id] as const,
categories: (type?: ComponentType) =>
[...templateKeys.all, "categories", type] as const,
installed: (appType?: AppType, componentType?: ComponentType) =>
[...templateKeys.all, "installed", appType, componentType] as const,
preview: (id: number) => [...templateKeys.all, "preview", id] as const,
bundles: () => [...templateKeys.all, "bundles"] as const,
};
// Hooks - 模板仓库
export function useTemplateRepos() {
return useQuery({
queryKey: templateKeys.repos(),
queryFn: templateApi.listTemplateRepos,
});
}
export function useAddTemplateRepo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: { owner: string; name: string; branch: string }) =>
templateApi.addTemplateRepo(params.owner, params.name, params.branch),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: templateKeys.repos() });
},
});
}
export function useRemoveTemplateRepo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => templateApi.removeTemplateRepo(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: templateKeys.repos() });
queryClient.invalidateQueries({ queryKey: templateKeys.components({}) });
},
});
}
export function useToggleTemplateRepo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: { id: number; enabled: boolean }) =>
templateApi.toggleTemplateRepo(params.id, params.enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: templateKeys.repos() });
},
});
}
// Hooks - 模板索引
export function useRefreshTemplateIndex() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: templateApi.refreshTemplateIndex,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: templateKeys.all });
},
});
}
// Hooks - 模板组件查询
export function useTemplateComponents(filter: ComponentFilter) {
return useQuery({
queryKey: templateKeys.components(filter),
queryFn: () => templateApi.listTemplateComponents(filter),
});
}
export function useTemplateComponent(id: number) {
return useQuery({
queryKey: templateKeys.component(id),
queryFn: () => templateApi.getTemplateComponent(id),
enabled: id > 0,
});
}
export function useComponentCategories(componentType?: ComponentType) {
return useQuery({
queryKey: templateKeys.categories(componentType),
queryFn: () => templateApi.getComponentCategories(componentType),
});
}
// Hooks - 组件安装
export function useInstallTemplateComponent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: { id: number; appType: AppType }) =>
templateApi.installTemplateComponent(params.id, params.appType),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: templateKeys.components({}),
});
queryClient.invalidateQueries({
queryKey: templateKeys.installed(),
});
},
});
}
export function useUninstallTemplateComponent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: { id: number; appType: AppType }) =>
templateApi.uninstallTemplateComponent(params.id, params.appType),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: templateKeys.components({}),
});
queryClient.invalidateQueries({
queryKey: templateKeys.installed(),
});
},
});
}
export function useBatchInstallComponents() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: { ids: number[]; appType: AppType }) =>
templateApi.batchInstallComponents(params.ids, params.appType),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: templateKeys.components({}),
});
queryClient.invalidateQueries({
queryKey: templateKeys.installed(),
});
},
});
}
export function useInstalledComponents(
appType?: AppType,
componentType?: ComponentType,
) {
return useQuery({
queryKey: templateKeys.installed(appType, componentType),
queryFn: () => templateApi.listInstalledComponents(appType, componentType),
});
}
// Hooks - 组件预览
export function useComponentPreview(id: number) {
return useQuery({
queryKey: templateKeys.preview(id),
queryFn: () => templateApi.previewComponentContent(id),
enabled: id > 0,
});
}
// Hooks - 市场组合
export function useMarketplaceBundles() {
return useQuery({
queryKey: templateKeys.bundles(),
queryFn: templateApi.listMarketplaceBundles,
});
}
+104
View File
@@ -0,0 +1,104 @@
// Template 功能相关类型定义
// 组件类型
export type ComponentType =
| "agent"
| "command"
| "mcp"
| "setting"
| "hook"
| "skill";
// 模板仓库
export interface TemplateRepo {
id: number | null;
owner: string;
name: string;
branch: string;
enabled: boolean;
createdAt?: string;
updatedAt?: string;
}
// 模板组件
export interface TemplateComponent {
id: number | null;
repoId: number;
componentType: ComponentType;
category: string | null;
name: string;
path: string;
description: string | null;
contentHash: string | null;
installed: boolean;
createdAt?: string;
updatedAt?: string;
}
// 组件详情(含完整内容)
export interface ComponentDetail extends TemplateComponent {
content: string;
repoOwner: string;
repoName: string;
repoBranch: string;
readmeUrl: string;
}
// 已安装组件
export interface InstalledComponent {
id: number | null;
componentId: number | null;
componentType: ComponentType;
name: string;
path: string;
appType: string;
installedAt: string;
}
// 分页结果
export interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// 组件过滤选项
export interface ComponentFilter {
componentType?: ComponentType;
category?: string;
search?: string;
page?: number;
pageSize?: number;
appType?: string;
}
// 批量安装结果
export interface BatchInstallResult {
success: number[];
failed: Array<[number, string]>;
}
// 组件元数据(从 YAML front matter 解析)
export interface ComponentMetadata {
name?: string;
description?: string;
tools?: string; // Agent 专用
model?: string; // Agent 专用
}
// 市场组合项(plugin 中的单个组件)
export interface MarketplaceBundleItem {
name: string;
path: string;
componentType: ComponentType;
}
// 市场组合(预设组件集合)
export interface MarketplaceBundle {
id: string;
name: string;
description: string;
category: string;
components: MarketplaceBundleItem[];
}