mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68e07b350d | |||
| 26486d543c | |||
| fe49a0c189 | |||
| 813d6adb06 | |||
| d745ab58c4 | |||
| c2adb1af46 | |||
| fdd539759e | |||
| 054a5e9e3b | |||
| cba8e8fdb3 | |||
| b18be24384 | |||
| 86288ee77e | |||
| c1e27d3cf2 | |||
| c5d3732b9f | |||
| d33f99aca4 | |||
| 42d1d23618 | |||
| 7bc9dbbbb0 | |||
| e002bdba25 | |||
| 5694353798 | |||
| 17c3dffe8c | |||
| 07ba3df0f4 | |||
| 1fe16aa388 | |||
| f738871ad1 | |||
| 753190a879 |
Generated
-1
@@ -727,7 +727,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
"sha2",
|
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-deep-link",
|
"tauri-plugin-deep-link",
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
|||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
rust_decimal = "1.33"
|
rust_decimal = "1.33"
|
||||||
uuid = { version = "1.11", features = ["v4"] }
|
uuid = { version = "1.11", features = ["v4"] }
|
||||||
sha2 = "0.10"
|
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
|||||||
use crate::services::ProviderService;
|
use crate::services::ProviderService;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::path::Path;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -97,9 +96,7 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
|||||||
|
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||||
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
let (local_version, local_error) = {
|
||||||
try_get_version_wsl(tool, &distro)
|
|
||||||
} else {
|
|
||||||
// 先尝试直接执行
|
// 先尝试直接执行
|
||||||
let direct_result = try_get_version(tool);
|
let direct_result = try_get_version(tool);
|
||||||
|
|
||||||
@@ -187,7 +184,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
(None, Some("not installed or not executable".to_string()))
|
(None, Some("未安装或无法执行".to_string()))
|
||||||
} else {
|
} else {
|
||||||
(Some(extract_version(raw)), None)
|
(Some(extract_version(raw)), None)
|
||||||
}
|
}
|
||||||
@@ -196,7 +193,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
Some(if err.is_empty() {
|
Some(if err.is_empty() {
|
||||||
"not installed or not executable".to_string()
|
"未安装或无法执行".to_string()
|
||||||
} else {
|
} else {
|
||||||
err
|
err
|
||||||
}),
|
}),
|
||||||
@@ -207,88 +204,6 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 校验 WSL 发行版名称是否合法
|
|
||||||
/// WSL 发行版名称只允许字母、数字、连字符和下划线
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn is_valid_wsl_distro_name(name: &str) -> bool {
|
|
||||||
!name.is_empty()
|
|
||||||
&& name.len() <= 64
|
|
||||||
&& name
|
|
||||||
.chars()
|
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
// 防御性断言:tool 只能是预定义的值
|
|
||||||
debug_assert!(
|
|
||||||
["claude", "codex", "gemini"].contains(&tool),
|
|
||||||
"unexpected tool name: {tool}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 校验 distro 名称,防止命令注入
|
|
||||||
if !is_valid_wsl_distro_name(distro) {
|
|
||||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = Command::new("wsl.exe")
|
|
||||||
.args([
|
|
||||||
"-d",
|
|
||||||
distro,
|
|
||||||
"--",
|
|
||||||
"sh",
|
|
||||||
"-lc",
|
|
||||||
&format!("{tool} --version"),
|
|
||||||
])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output();
|
|
||||||
|
|
||||||
match output {
|
|
||||||
Ok(out) => {
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
|
||||||
if out.status.success() {
|
|
||||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
|
||||||
if raw.is_empty() {
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
Some(format!("[WSL:{distro}] not installed or not executable")),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(Some(extract_version(raw)), None)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let err = if stderr.is_empty() { stdout } else { stderr };
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
Some(format!(
|
|
||||||
"[WSL:{distro}] {}",
|
|
||||||
if err.is_empty() {
|
|
||||||
"not installed or not executable".to_string()
|
|
||||||
} else {
|
|
||||||
err
|
|
||||||
}
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 非 Windows 平台的 WSL 版本检测存根
|
|
||||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
|
||||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
Some("WSL check not supported on this platform".to_string()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描常见路径查找 CLI
|
/// 扫描常见路径查找 CLI
|
||||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -384,49 +299,7 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(None, Some("not installed or not executable".to_string()))
|
(None, Some("未安装或无法执行".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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开指定提供商的终端
|
/// 打开指定提供商的终端
|
||||||
@@ -532,7 +405,7 @@ fn launch_terminal_with_env(
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
||||||
Ok(())
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -583,7 +456,8 @@ fn escape_shell_path(path: &std::path::Path) -> String {
|
|||||||
/// 生成 bash 包装脚本,用于清理临时文件
|
/// 生成 bash 包装脚本,用于清理临时文件
|
||||||
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
|
"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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ mod proxy;
|
|||||||
mod settings;
|
mod settings;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
mod stream_check;
|
mod stream_check;
|
||||||
mod template;
|
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
@@ -33,5 +32,4 @@ pub use proxy::*;
|
|||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use skill::*;
|
pub use skill::*;
|
||||||
pub use stream_check::*;
|
pub use stream_check::*;
|
||||||
pub use template::*;
|
|
||||||
pub use usage::*;
|
pub use usage::*;
|
||||||
|
|||||||
@@ -59,24 +59,3 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
|||||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||||
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取整流器配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_rectifier_config(
|
|
||||||
state: tauri::State<'_, crate::AppState>,
|
|
||||||
) -> Result<crate::proxy::types::RectifierConfig, String> {
|
|
||||||
state.db.get_rectifier_config().map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置整流器配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_rectifier_config(
|
|
||||||
state: tauri::State<'_, crate::AppState>,
|
|
||||||
config: crate::proxy::types::RectifierConfig,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.set_rectifier_config(&config)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::database::lock_conn;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::{
|
|
||||||
BatchInstallResult, ComponentDetail, InstalledComponent, PaginatedResult, TemplateComponent,
|
|
||||||
TemplateRepo, TemplateService,
|
|
||||||
};
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 刷新模板索引
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn refresh_template_index(state: State<'_, AppState>) -> Result<(), String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
// 使用 spawn_blocking 在后台线程中执行数据库操作
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.refresh_index(&conn)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取模板组件列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_template_components(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
component_type: Option<String>,
|
|
||||||
category: Option<String>,
|
|
||||||
search: Option<String>,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
app_type: Option<String>,
|
|
||||||
) -> Result<PaginatedResult<TemplateComponent>, AppError> {
|
|
||||||
let (mut components, total) = state.db.list_components(
|
|
||||||
component_type.as_deref(),
|
|
||||||
category.as_deref(),
|
|
||||||
search.as_deref(),
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 填充 installed 字段
|
|
||||||
if let Some(app) = &app_type {
|
|
||||||
let installed_ids = state.db.get_installed_component_ids(app)?;
|
|
||||||
for component in &mut components {
|
|
||||||
if let Some(id) = component.id {
|
|
||||||
component.installed = installed_ids.contains(&id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(PaginatedResult {
|
|
||||||
items: components,
|
|
||||||
total: total as i64,
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取组件详情
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_template_component(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
) -> Result<ComponentDetail, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
let detail = tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.get_component(&conn, id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装组件
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn install_template_component(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.install_component(&conn, id, &app_type)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 卸载组件
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn uninstall_template_component(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let service = TemplateService::new().map_err(|e| AppError::Config(e.to_string()))?;
|
|
||||||
let conn = lock_conn!(state.db.conn);
|
|
||||||
|
|
||||||
service
|
|
||||||
.uninstall_component(&conn, id, &app_type)
|
|
||||||
.map_err(|e| AppError::Config(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量安装组件
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn batch_install_template_components(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
ids: Vec<i64>,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<BatchInstallResult, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
let result = tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.batch_install(&conn, ids, &app_type)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取模板仓库列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_template_repos(state: State<'_, AppState>) -> Result<Vec<TemplateRepo>, AppError> {
|
|
||||||
state.db.list_repos()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加模板仓库
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn add_template_repo(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
owner: String,
|
|
||||||
name: String,
|
|
||||||
branch: String,
|
|
||||||
) -> Result<i64, AppError> {
|
|
||||||
let repo = TemplateRepo::new(owner, name, branch);
|
|
||||||
state.db.insert_repo(&repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模板仓库
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn remove_template_repo(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
|
|
||||||
state.db.delete_repo(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换仓库启用状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn toggle_template_repo(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
state.db.toggle_repo_enabled(id, enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取组件分类列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_template_categories(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
component_type: Option<String>,
|
|
||||||
) -> Result<Vec<String>, AppError> {
|
|
||||||
let conn = lock_conn!(state.db.conn);
|
|
||||||
|
|
||||||
// 构建查询语句
|
|
||||||
let sql = if let Some(ct) = component_type {
|
|
||||||
format!(
|
|
||||||
"SELECT DISTINCT category FROM template_components WHERE component_type = '{ct}' AND category IS NOT NULL ORDER BY category"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
"SELECT DISTINCT category FROM template_components WHERE category IS NOT NULL ORDER BY category".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
|
||||||
let categories = stmt
|
|
||||||
.query_map([], |row| row.get::<_, String>(0))?
|
|
||||||
.collect::<Result<Vec<String>, _>>()?;
|
|
||||||
|
|
||||||
Ok(categories)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取已安装组件列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_installed_components(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app_type: Option<String>,
|
|
||||||
component_type: Option<String>,
|
|
||||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.list_installed_components(app_type.as_deref(), component_type.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 预览组件内容
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn preview_component_content(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.preview_content(&conn, id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取市场组合列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_marketplace_bundles(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<Vec<crate::services::MarketplaceBundle>, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.fetch_marketplace_bundles(&conn)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ pub mod proxy;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod stream_check;
|
pub mod stream_check;
|
||||||
pub mod template;
|
|
||||||
pub mod universal_providers;
|
pub mod universal_providers;
|
||||||
|
|
||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
|
|||||||
@@ -163,27 +163,4 @@ impl Database {
|
|||||||
log::info!("已清除所有代理接管状态");
|
log::info!("已清除所有代理接管状态");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 整流器配置 ---
|
|
||||||
|
|
||||||
/// 获取整流器配置
|
|
||||||
///
|
|
||||||
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
|
||||||
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
|
||||||
match self.get_setting("rectifier_config")? {
|
|
||||||
Some(json) => serde_json::from_str(&json)
|
|
||||||
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
|
|
||||||
None => Ok(crate::proxy::types::RectifierConfig::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新整流器配置
|
|
||||||
pub fn set_rectifier_config(
|
|
||||||
&self,
|
|
||||||
config: &crate::proxy::types::RectifierConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let json = serde_json::to_string(config)
|
|
||||||
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
|
|
||||||
self.set_setting("rectifier_config", &json)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
//! Template 数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供 Template Repos、Template Components 和 Installed Components 的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::template::{
|
|
||||||
ComponentType, InstalledComponent, TemplateComponent, TemplateRepo,
|
|
||||||
};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use rusqlite::{params, OptionalExtension};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
// ==================== TemplateRepo 相关 ====================
|
|
||||||
|
|
||||||
/// 插入模板仓库
|
|
||||||
pub fn insert_repo(&self, repo: &TemplateRepo) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_repos (owner, name, branch, enabled, created_at, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
params![repo.owner, repo.name, repo.branch, repo.enabled, now, now],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个模板仓库
|
|
||||||
pub fn get_repo(&self, id: i64) -> Result<Option<TemplateRepo>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
WHERE id = ?1",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
let repo = stmt
|
|
||||||
.query_row(params![id], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get(4)?,
|
|
||||||
created_at: row
|
|
||||||
.get::<_, String>(5)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
updated_at: row
|
|
||||||
.get::<_, String>(6)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.map_err(|e| AppError::Database(format!("查询模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取所有模板仓库
|
|
||||||
pub fn list_repos(&self) -> Result<Vec<TemplateRepo>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库列表失败: {e}")))?;
|
|
||||||
|
|
||||||
let repo_iter = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get(4)?,
|
|
||||||
created_at: row
|
|
||||||
.get::<_, String>(5)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
updated_at: row
|
|
||||||
.get::<_, String>(6)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询模板仓库列表失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut repos = Vec::new();
|
|
||||||
for repo_res in repo_iter {
|
|
||||||
repos.push(repo_res.map_err(|e| AppError::Database(format!("解析模板仓库失败: {e}")))?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新模板仓库
|
|
||||||
pub fn update_repo(&self, repo: &TemplateRepo) -> Result<(), AppError> {
|
|
||||||
let repo_id = repo
|
|
||||||
.id
|
|
||||||
.ok_or_else(|| AppError::Database("仓库 ID 不能为空".to_string()))?;
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos
|
|
||||||
SET owner = ?1, name = ?2, branch = ?3, enabled = ?4, updated_at = ?5
|
|
||||||
WHERE id = ?6",
|
|
||||||
params![
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
repo.branch,
|
|
||||||
repo.enabled,
|
|
||||||
now,
|
|
||||||
repo_id
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("更新模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模板仓库
|
|
||||||
pub fn delete_repo(&self, id: i64) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
|
||||||
.map_err(|e| AppError::Database(format!("删除模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换仓库启用状态
|
|
||||||
pub fn toggle_repo_enabled(&self, id: i64, enabled: bool) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos SET enabled = ?1, updated_at = ?2 WHERE id = ?3",
|
|
||||||
params![enabled, now, id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("切换仓库启用状态失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== TemplateComponent 相关 ====================
|
|
||||||
|
|
||||||
/// 插入模板组件
|
|
||||||
pub fn insert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_components
|
|
||||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
component.category,
|
|
||||||
component.name,
|
|
||||||
component.path,
|
|
||||||
component.description,
|
|
||||||
component.content_hash,
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个模板组件
|
|
||||||
pub fn get_component(&self, id: i64) -> Result<Option<TemplateComponent>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let component = stmt
|
|
||||||
.query_row(params![id], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false, // 需要单独查询
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.map_err(|e| AppError::Database(format!("查询模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(component)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取组件列表(支持过滤和分页)
|
|
||||||
pub fn list_components(
|
|
||||||
&self,
|
|
||||||
component_type: Option<&str>,
|
|
||||||
category: Option<&str>,
|
|
||||||
search: Option<&str>,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
) -> Result<(Vec<TemplateComponent>, u32), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 构建 WHERE 子句
|
|
||||||
let mut where_clauses = Vec::new();
|
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(ct) = component_type {
|
|
||||||
where_clauses.push("component_type = ?");
|
|
||||||
params_vec.push(Box::new(ct.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cat) = category {
|
|
||||||
where_clauses.push("category = ?");
|
|
||||||
params_vec.push(Box::new(cat.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(s) = search {
|
|
||||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
|
||||||
let pattern = format!("%{s}%");
|
|
||||||
params_vec.push(Box::new(pattern.clone()));
|
|
||||||
params_vec.push(Box::new(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_sql = if where_clauses.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", where_clauses.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查询总数
|
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
|
||||||
|
|
||||||
let total: u32 = {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&count_sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备统计组件数量失败: {e}")))?;
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
stmt.query_row(¶ms_refs[..], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(format!("统计组件数量失败: {e}")))?
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查询数据
|
|
||||||
let offset = (page.saturating_sub(1)) * page_size;
|
|
||||||
let query_sql = format!(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY name ASC
|
|
||||||
LIMIT ? OFFSET ?"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&query_sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询组件列表失败: {e}")))?;
|
|
||||||
|
|
||||||
params_vec.push(Box::new(page_size));
|
|
||||||
params_vec.push(Box::new(offset));
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let component_iter = stmt
|
|
||||||
.query_map(¶ms_refs[..], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false, // 需要单独查询
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询组件列表失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut components = Vec::new();
|
|
||||||
for component_res in component_iter {
|
|
||||||
components
|
|
||||||
.push(component_res.map_err(|e| AppError::Database(format!("解析组件失败: {e}")))?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((components, total))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除仓库的所有组件
|
|
||||||
pub fn delete_components_by_repo(&self, repo_id: i64) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM template_components WHERE repo_id = ?1",
|
|
||||||
params![repo_id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("删除仓库组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upsert 模板组件(根据 repo_id + component_type + path 判断是否已存在)
|
|
||||||
pub fn upsert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_components
|
|
||||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
||||||
ON CONFLICT(repo_id, component_type, path) DO UPDATE SET
|
|
||||||
category = excluded.category,
|
|
||||||
name = excluded.name,
|
|
||||||
description = excluded.description,
|
|
||||||
content_hash = excluded.content_hash,
|
|
||||||
updated_at = excluded.updated_at",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
component.category,
|
|
||||||
component.name,
|
|
||||||
component.path,
|
|
||||||
component.description,
|
|
||||||
component.content_hash,
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Upsert 模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== InstalledComponent 相关 ====================
|
|
||||||
|
|
||||||
/// 插入已安装组件
|
|
||||||
pub fn insert_installed(&self, installed: &InstalledComponent) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let installed_at = installed.installed_at.to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO installed_components
|
|
||||||
(component_id, component_type, name, path, app_type, installed_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
params![
|
|
||||||
installed.component_id,
|
|
||||||
installed.component_type.as_str(),
|
|
||||||
installed.name,
|
|
||||||
installed.path,
|
|
||||||
installed.app_type,
|
|
||||||
installed_at
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除已安装组件
|
|
||||||
pub fn delete_installed(
|
|
||||||
&self,
|
|
||||||
component_type: &str,
|
|
||||||
path: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM installed_components
|
|
||||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
|
||||||
params![component_type, path, app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("删除已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取已安装组件列表
|
|
||||||
pub fn list_installed(
|
|
||||||
&self,
|
|
||||||
app_type: Option<&str>,
|
|
||||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type
|
|
||||||
{
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
WHERE app_type = ?
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![Box::new(at.to_string())],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let installed_iter = stmt
|
|
||||||
.query_map(¶ms_refs[..], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
let installed_at_str: String = row.get(6)?;
|
|
||||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(InstalledComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
component_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
name: row.get(3)?,
|
|
||||||
path: row.get(4)?,
|
|
||||||
app_type: row.get(5)?,
|
|
||||||
installed_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut installed = Vec::new();
|
|
||||||
for installed_res in installed_iter {
|
|
||||||
installed.push(
|
|
||||||
installed_res
|
|
||||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(installed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取已安装组件列表(支持 app_type 和 component_type 过滤)
|
|
||||||
pub fn list_installed_components(
|
|
||||||
&self,
|
|
||||||
app_type: Option<&str>,
|
|
||||||
component_type: Option<&str>,
|
|
||||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 构建 WHERE 子句
|
|
||||||
let mut where_clauses = Vec::new();
|
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(at) = app_type {
|
|
||||||
where_clauses.push("app_type = ?");
|
|
||||||
params_vec.push(Box::new(at.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ct) = component_type {
|
|
||||||
where_clauses.push("component_type = ?");
|
|
||||||
params_vec.push(Box::new(ct.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_sql = if where_clauses.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", where_clauses.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let installed_iter = stmt
|
|
||||||
.query_map(¶ms_refs[..], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
let installed_at_str: String = row.get(6)?;
|
|
||||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(InstalledComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
component_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
name: row.get(3)?,
|
|
||||||
path: row.get(4)?,
|
|
||||||
app_type: row.get(5)?,
|
|
||||||
installed_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut installed = Vec::new();
|
|
||||||
for installed_res in installed_iter {
|
|
||||||
installed.push(
|
|
||||||
installed_res
|
|
||||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(installed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查组件是否已安装
|
|
||||||
pub fn is_installed(
|
|
||||||
&self,
|
|
||||||
component_type: &str,
|
|
||||||
path: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM installed_components
|
|
||||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
|
||||||
params![component_type, path, app_type],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("检查组件安装状态失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取指定应用已安装的组件 ID 列表
|
|
||||||
pub fn get_installed_component_ids(&self, app_type: &str) -> Result<Vec<i64>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT component_id FROM installed_components WHERE app_type = ?1 AND component_id IS NOT NULL",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let ids: Vec<i64> = stmt
|
|
||||||
.query_map(params![app_type], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -302,90 +302,6 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 15. Template Repos 表 (模板仓库)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 插入默认模板仓库
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
|
|
||||||
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 16. Template Components 表 (模板组件)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
repo_id INTEGER NOT NULL,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
category TEXT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
content_hash TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(repo_id, component_type, path)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 为 template_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_type
|
|
||||||
ON template_components(component_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_category
|
|
||||||
ON template_components(category)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 17. Installed Components 表 (已安装组件)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS installed_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
component_id INTEGER,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(component_type, path, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 为 installed_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
|
|
||||||
ON installed_components(app_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,12 +346,6 @@ impl Database {
|
|||||||
Self::migrate_v2_to_v3(conn)?;
|
Self::migrate_v2_to_v3(conn)?;
|
||||||
Self::set_user_version(conn, 3)?;
|
Self::set_user_version(conn, 3)?;
|
||||||
}
|
}
|
||||||
// v3 -> v4: Claude Code Templates 市场功能(暂未启用)
|
|
||||||
// 3 => {
|
|
||||||
// log::info!("迁移数据库从 v3 到 v4(Claude Code Templates 市场功能)");
|
|
||||||
// Self::migrate_v3_to_v4(conn)?;
|
|
||||||
// Self::set_user_version(conn, 4)?;
|
|
||||||
// }
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::Database(format!(
|
return Err(AppError::Database(format!(
|
||||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||||
@@ -876,97 +786,6 @@ impl Database {
|
|||||||
Ok(())
|
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 统一管理架构
|
/// v2 -> v3 迁移:Skills 统一管理架构
|
||||||
///
|
///
|
||||||
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
||||||
|
|||||||
@@ -518,7 +518,8 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
count > 0,
|
count > 0,
|
||||||
"模型定价数据应该在初始化时自动填充,实际数量: {count}"
|
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
||||||
|
count
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证包含 Claude 模型
|
// 验证包含 Claude 模型
|
||||||
@@ -531,7 +532,8 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
.expect("check claude");
|
.expect("check claude");
|
||||||
assert!(
|
assert!(
|
||||||
claude_count > 0,
|
claude_count > 0,
|
||||||
"应该包含 Claude 模型定价,实际数量: {claude_count}"
|
"应该包含 Claude 模型定价,实际数量: {}",
|
||||||
|
claude_count
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证包含 GPT 模型
|
// 验证包含 GPT 模型
|
||||||
@@ -544,7 +546,8 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
.expect("check gpt");
|
.expect("check gpt");
|
||||||
assert!(
|
assert!(
|
||||||
gpt_count > 0,
|
gpt_count > 0,
|
||||||
"应该包含 GPT 模型定价,实际数量: {gpt_count}"
|
"应该包含 GPT 模型定价,实际数量: {}",
|
||||||
|
gpt_count
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证包含 Gemini 模型
|
// 验证包含 Gemini 模型
|
||||||
@@ -557,90 +560,7 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
.expect("check gemini");
|
.expect("check gemini");
|
||||||
assert!(
|
assert!(
|
||||||
gemini_count > 0,
|
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 个外键约束");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -365,7 +365,8 @@ fn test_parse_prompt_deeplink() {
|
|||||||
let content = "Hello World";
|
let content = "Hello World";
|
||||||
let content_b64 = BASE64_STANDARD.encode(content);
|
let content_b64 = BASE64_STANDARD.encode(content);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={content_b64}&description=desc&enabled=true"
|
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true",
|
||||||
|
content_b64
|
||||||
);
|
);
|
||||||
|
|
||||||
let request = parse_deeplink_url(&url).unwrap();
|
let request = parse_deeplink_url(&url).unwrap();
|
||||||
@@ -382,7 +383,8 @@ fn test_parse_mcp_deeplink() {
|
|||||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||||
let config_b64 = BASE64_STANDARD.encode(config);
|
let config_b64 = BASE64_STANDARD.encode(config);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={config_b64}&enabled=true"
|
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true",
|
||||||
|
config_b64
|
||||||
);
|
);
|
||||||
|
|
||||||
let request = parse_deeplink_url(&url).unwrap();
|
let request = parse_deeplink_url(&url).unwrap();
|
||||||
|
|||||||
@@ -734,8 +734,6 @@ pub fn run() {
|
|||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
commands::get_rectifier_config,
|
|
||||||
commands::set_rectifier_config,
|
|
||||||
commands::restart_app,
|
commands::restart_app,
|
||||||
commands::check_for_updates,
|
commands::check_for_updates,
|
||||||
commands::is_portable_mode,
|
commands::is_portable_mode,
|
||||||
@@ -880,22 +878,6 @@ pub fn run() {
|
|||||||
commands::test_proxy_url,
|
commands::test_proxy_url,
|
||||||
commands::get_upstream_proxy_status,
|
commands::get_upstream_proxy_status,
|
||||||
commands::scan_local_proxies,
|
commands::scan_local_proxies,
|
||||||
|
|
||||||
// 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
|
let app = builder
|
||||||
|
|||||||
@@ -319,11 +319,7 @@ impl CircuitBreaker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 仅释放 HalfOpen permit,不影响健康统计
|
fn release_half_open_permit(&self) {
|
||||||
///
|
|
||||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
|
||||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
|
||||||
pub fn release_half_open_permit(&self) {
|
|
||||||
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||||
loop {
|
loop {
|
||||||
if current == 0 {
|
if current == 0 {
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ use super::{
|
|||||||
error::*,
|
error::*,
|
||||||
failover_switch::FailoverSwitchManager,
|
failover_switch::FailoverSwitchManager,
|
||||||
provider_router::ProviderRouter,
|
provider_router::ProviderRouter,
|
||||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
providers::{get_adapter, ProviderAdapter},
|
||||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
types::ProxyStatus,
|
||||||
types::{ProxyStatus, RectifierConfig},
|
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use crate::{app_config::AppType, provider::Provider};
|
use crate::{app_config::AppType, provider::Provider};
|
||||||
@@ -91,8 +90,6 @@ pub struct RequestForwarder {
|
|||||||
app_handle: Option<tauri::AppHandle>,
|
app_handle: Option<tauri::AppHandle>,
|
||||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
/// 整流器配置
|
|
||||||
rectifier_config: RectifierConfig,
|
|
||||||
/// 非流式请求超时(秒)
|
/// 非流式请求超时(秒)
|
||||||
non_streaming_timeout: std::time::Duration,
|
non_streaming_timeout: std::time::Duration,
|
||||||
}
|
}
|
||||||
@@ -109,7 +106,6 @@ impl RequestForwarder {
|
|||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
_streaming_first_byte_timeout: u64,
|
_streaming_first_byte_timeout: u64,
|
||||||
_streaming_idle_timeout: u64,
|
_streaming_idle_timeout: u64,
|
||||||
rectifier_config: RectifierConfig,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
router,
|
router,
|
||||||
@@ -118,7 +114,6 @@ impl RequestForwarder {
|
|||||||
failover_manager,
|
failover_manager,
|
||||||
app_handle,
|
app_handle,
|
||||||
current_provider_id_at_start,
|
current_provider_id_at_start,
|
||||||
rectifier_config,
|
|
||||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +130,7 @@ impl RequestForwarder {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
mut body: Value,
|
body: Value,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
providers: Vec<Provider>,
|
providers: Vec<Provider>,
|
||||||
) -> Result<ForwardResult, ForwardError> {
|
) -> Result<ForwardResult, ForwardError> {
|
||||||
@@ -154,9 +149,6 @@ impl RequestForwarder {
|
|||||||
let mut last_provider = None;
|
let mut last_provider = None;
|
||||||
let mut attempted_providers = 0usize;
|
let mut attempted_providers = 0usize;
|
||||||
|
|
||||||
// 整流器重试标记:确保整流最多触发一次
|
|
||||||
let mut rectifier_retried = false;
|
|
||||||
|
|
||||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||||
let bypass_circuit_breaker = providers.len() == 1;
|
let bypass_circuit_breaker = providers.len() == 1;
|
||||||
|
|
||||||
@@ -251,205 +243,6 @@ impl RequestForwarder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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
|
let _ = self
|
||||||
.router
|
.router
|
||||||
@@ -711,11 +504,3 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 ProxyError 中提取错误消息
|
|
||||||
fn extract_error_message(error: &ProxyError) -> Option<String> {
|
|
||||||
match error {
|
|
||||||
ProxyError::UpstreamError { body, .. } => body.clone(),
|
|
||||||
_ => Some(error.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::{
|
use crate::proxy::{
|
||||||
extract_session_id,
|
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
|
||||||
forwarder::RequestForwarder,
|
|
||||||
server::ProxyState,
|
|
||||||
types::{AppProxyConfig, RectifierConfig},
|
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
@@ -57,8 +54,6 @@ pub struct RequestContext {
|
|||||||
pub app_type: AppType,
|
pub app_type: AppType,
|
||||||
/// Session ID(从客户端请求提取或新生成)
|
/// Session ID(从客户端请求提取或新生成)
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
/// 整流器配置
|
|
||||||
pub rectifier_config: RectifierConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestContext {
|
impl RequestContext {
|
||||||
@@ -91,9 +86,6 @@ impl RequestContext {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
// 从数据库读取整流器配置
|
|
||||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
|
||||||
|
|
||||||
let current_provider_id =
|
let current_provider_id =
|
||||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||||
|
|
||||||
@@ -155,7 +147,6 @@ impl RequestContext {
|
|||||||
app_type_str,
|
app_type_str,
|
||||||
app_type,
|
app_type,
|
||||||
session_id,
|
session_id,
|
||||||
rectifier_config,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +206,6 @@ impl RequestContext {
|
|||||||
self.current_provider_id.clone(),
|
self.current_provider_id.clone(),
|
||||||
first_byte_timeout,
|
first_byte_timeout,
|
||||||
idle_timeout,
|
idle_timeout,
|
||||||
self.rectifier_config.clone(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ pub mod response_handler;
|
|||||||
pub mod response_processor;
|
pub mod response_processor;
|
||||||
pub(crate) mod server;
|
pub(crate) mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod thinking_rectifier;
|
|
||||||
pub(crate) mod types;
|
pub(crate) mod types;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|
||||||
|
|||||||
@@ -151,24 +151,6 @@ impl ProviderRouter {
|
|||||||
self.reset_circuit_breaker(&circuit_key).await;
|
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) {
|
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
||||||
let breakers = self.circuit_breakers.read().await;
|
let breakers = self.circuit_breakers.read().await;
|
||||||
@@ -343,55 +325,4 @@ mod tests {
|
|||||||
|
|
||||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
//! Thinking Signature 整流器
|
|
||||||
//!
|
|
||||||
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
|
|
||||||
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
|
|
||||||
|
|
||||||
use super::types::RectifierConfig;
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
/// 整流结果
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct RectifyResult {
|
|
||||||
/// 是否应用了整流
|
|
||||||
pub applied: bool,
|
|
||||||
/// 移除的 thinking block 数量
|
|
||||||
pub removed_thinking_blocks: usize,
|
|
||||||
/// 移除的 redacted_thinking block 数量
|
|
||||||
pub removed_redacted_thinking_blocks: usize,
|
|
||||||
/// 移除的 signature 字段数量
|
|
||||||
pub removed_signature_fields: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检测是否需要触发 thinking 签名整流器
|
|
||||||
///
|
|
||||||
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
|
|
||||||
/// 会检查配置开关。
|
|
||||||
pub fn should_rectify_thinking_signature(
|
|
||||||
error_message: Option<&str>,
|
|
||||||
config: &RectifierConfig,
|
|
||||||
) -> bool {
|
|
||||||
// 检查总开关
|
|
||||||
if !config.enabled {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// 检查子开关
|
|
||||||
if !config.request_thinking_signature {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检测错误类型
|
|
||||||
let Some(msg) = error_message else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let lower = msg.to_lowercase();
|
|
||||||
|
|
||||||
// 场景1: thinking block 中的签名无效
|
|
||||||
// 错误示例: "Invalid 'signature' in 'thinking' block"
|
|
||||||
if lower.contains("invalid")
|
|
||||||
&& lower.contains("signature")
|
|
||||||
&& lower.contains("thinking")
|
|
||||||
&& lower.contains("block")
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 场景2: assistant 消息必须以 thinking block 开头
|
|
||||||
// 错误示例: "must start with a thinking block"
|
|
||||||
if lower.contains("must start with a thinking block") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 场景3: expected thinking or redacted_thinking, found tool_use
|
|
||||||
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
|
|
||||||
if lower.contains("expected")
|
|
||||||
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
|
||||||
&& lower.contains("found")
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 场景4: signature 字段必需但缺失
|
|
||||||
// 错误示例: "signature: Field required"
|
|
||||||
if lower.contains("signature") && lower.contains("field required") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 对 Anthropic 请求体做最小侵入整流
|
|
||||||
///
|
|
||||||
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
|
|
||||||
/// - 移除非 thinking block 上遗留的 signature 字段
|
|
||||||
/// - 特定条件下删除顶层 thinking 字段
|
|
||||||
///
|
|
||||||
/// 注意:该函数会原地修改 body 对象
|
|
||||||
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
|
|
||||||
let mut result = RectifyResult::default();
|
|
||||||
|
|
||||||
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
|
||||||
Some(m) => m,
|
|
||||||
None => return result,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 遍历所有消息
|
|
||||||
for msg in messages.iter_mut() {
|
|
||||||
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut new_content = Vec::with_capacity(content.len());
|
|
||||||
let mut content_modified = false;
|
|
||||||
|
|
||||||
for block in content.iter() {
|
|
||||||
let block_type = block.get("type").and_then(|t| t.as_str());
|
|
||||||
|
|
||||||
match block_type {
|
|
||||||
Some("thinking") => {
|
|
||||||
result.removed_thinking_blocks += 1;
|
|
||||||
content_modified = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Some("redacted_thinking") => {
|
|
||||||
result.removed_redacted_thinking_blocks += 1;
|
|
||||||
content_modified = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除非 thinking block 上的 signature 字段
|
|
||||||
if block.get("signature").is_some() {
|
|
||||||
let mut block_clone = block.clone();
|
|
||||||
if let Some(obj) = block_clone.as_object_mut() {
|
|
||||||
obj.remove("signature");
|
|
||||||
result.removed_signature_fields += 1;
|
|
||||||
content_modified = true;
|
|
||||||
new_content.push(Value::Object(obj.clone()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
new_content.push(block.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if content_modified {
|
|
||||||
result.applied = true;
|
|
||||||
*content = new_content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
|
|
||||||
let messages_snapshot: Vec<Value> = body
|
|
||||||
.get("messages")
|
|
||||||
.and_then(|m| m.as_array())
|
|
||||||
.map(|a| a.to_vec())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if should_remove_top_level_thinking(body, &messages_snapshot) {
|
|
||||||
if let Some(obj) = body.as_object_mut() {
|
|
||||||
obj.remove("thinking");
|
|
||||||
result.applied = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断是否需要删除顶层 thinking 字段
|
|
||||||
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
|
||||||
// 检查 thinking 是否启用
|
|
||||||
let thinking_enabled = body
|
|
||||||
.get("thinking")
|
|
||||||
.and_then(|t| t.get("type"))
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
== Some("enabled");
|
|
||||||
|
|
||||||
if !thinking_enabled {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 找到最后一条 assistant 消息
|
|
||||||
let last_assistant = messages
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
|
|
||||||
|
|
||||||
let last_assistant_content = match last_assistant
|
|
||||||
.and_then(|m| m.get("content"))
|
|
||||||
.and_then(|c| c.as_array())
|
|
||||||
{
|
|
||||||
Some(c) if !c.is_empty() => c,
|
|
||||||
_ => return false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查首块是否为 thinking/redacted_thinking
|
|
||||||
let first_block_type = last_assistant_content
|
|
||||||
.first()
|
|
||||||
.and_then(|b| b.get("type"))
|
|
||||||
.and_then(|t| t.as_str());
|
|
||||||
|
|
||||||
let missing_thinking_prefix =
|
|
||||||
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
|
|
||||||
|
|
||||||
if !missing_thinking_prefix {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否存在 tool_use
|
|
||||||
last_assistant_content
|
|
||||||
.iter()
|
|
||||||
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
fn enabled_config() -> RectifierConfig {
|
|
||||||
RectifierConfig {
|
|
||||||
enabled: true,
|
|
||||||
request_thinking_signature: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disabled_config() -> RectifierConfig {
|
|
||||||
RectifierConfig {
|
|
||||||
enabled: true,
|
|
||||||
request_thinking_signature: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn master_disabled_config() -> RectifierConfig {
|
|
||||||
RectifierConfig {
|
|
||||||
enabled: false,
|
|
||||||
request_thinking_signature: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== should_rectify_thinking_signature 测试 ====================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_detect_invalid_signature() {
|
|
||||||
assert!(should_rectify_thinking_signature(
|
|
||||||
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
|
|
||||||
&enabled_config()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_detect_invalid_signature_no_backticks() {
|
|
||||||
assert!(should_rectify_thinking_signature(
|
|
||||||
Some("Messages.1.Content.0: invalid signature in thinking block"),
|
|
||||||
&enabled_config()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_detect_invalid_signature_nested_json() {
|
|
||||||
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
|
|
||||||
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
|
|
||||||
assert!(should_rectify_thinking_signature(
|
|
||||||
Some(nested_error),
|
|
||||||
&enabled_config()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_detect_thinking_expected() {
|
|
||||||
assert!(should_rectify_thinking_signature(
|
|
||||||
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
|
|
||||||
&enabled_config()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_detect_must_start_with_thinking() {
|
|
||||||
assert!(should_rectify_thinking_signature(
|
|
||||||
Some("a final `assistant` message must start with a thinking block"),
|
|
||||||
&enabled_config()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_trigger_for_unrelated_error() {
|
|
||||||
assert!(!should_rectify_thinking_signature(
|
|
||||||
Some("Request timeout"),
|
|
||||||
&enabled_config()
|
|
||||||
));
|
|
||||||
assert!(!should_rectify_thinking_signature(
|
|
||||||
Some("Connection refused"),
|
|
||||||
&enabled_config()
|
|
||||||
));
|
|
||||||
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_detect_signature_field_required() {
|
|
||||||
// 场景4: signature 字段缺失
|
|
||||||
assert!(should_rectify_thinking_signature(
|
|
||||||
Some("***.***.***.***.***.signature: Field required"),
|
|
||||||
&enabled_config()
|
|
||||||
));
|
|
||||||
// 嵌套 JSON 格式
|
|
||||||
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
|
|
||||||
assert!(should_rectify_thinking_signature(
|
|
||||||
Some(nested_error),
|
|
||||||
&enabled_config()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_disabled_config() {
|
|
||||||
// 即使错误匹配,配置关闭时也不触发
|
|
||||||
assert!(!should_rectify_thinking_signature(
|
|
||||||
Some("Invalid `signature` in `thinking` block"),
|
|
||||||
&disabled_config()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_master_disabled() {
|
|
||||||
// 总开关关闭时,即使子开关开启也不触发
|
|
||||||
assert!(!should_rectify_thinking_signature(
|
|
||||||
Some("Invalid `signature` in `thinking` block"),
|
|
||||||
&master_disabled_config()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== rectify_anthropic_request 测试 ====================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rectify_removes_thinking_blocks() {
|
|
||||||
let mut body = json!({
|
|
||||||
"model": "claude-test",
|
|
||||||
"messages": [{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{ "type": "thinking", "thinking": "t", "signature": "sig" },
|
|
||||||
{ "type": "text", "text": "hello", "signature": "sig_text" },
|
|
||||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
|
|
||||||
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = rectify_anthropic_request(&mut body);
|
|
||||||
|
|
||||||
assert!(result.applied);
|
|
||||||
assert_eq!(result.removed_thinking_blocks, 1);
|
|
||||||
assert_eq!(result.removed_redacted_thinking_blocks, 1);
|
|
||||||
assert_eq!(result.removed_signature_fields, 2);
|
|
||||||
|
|
||||||
let content = body["messages"][0]["content"].as_array().unwrap();
|
|
||||||
assert_eq!(content.len(), 2);
|
|
||||||
assert_eq!(content[0]["type"], "text");
|
|
||||||
assert!(content[0].get("signature").is_none());
|
|
||||||
assert_eq!(content[1]["type"], "tool_use");
|
|
||||||
assert!(content[1].get("signature").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rectify_removes_top_level_thinking() {
|
|
||||||
let mut body = json!({
|
|
||||||
"model": "claude-test",
|
|
||||||
"thinking": { "type": "enabled", "budget_tokens": 1024 },
|
|
||||||
"messages": [{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
|
|
||||||
]
|
|
||||||
}, {
|
|
||||||
"role": "user",
|
|
||||||
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = rectify_anthropic_request(&mut body);
|
|
||||||
|
|
||||||
assert!(result.applied);
|
|
||||||
assert!(body.get("thinking").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rectify_no_change_when_no_issues() {
|
|
||||||
let mut body = json!({
|
|
||||||
"model": "claude-test",
|
|
||||||
"messages": [{
|
|
||||||
"role": "user",
|
|
||||||
"content": [{ "type": "text", "text": "hello" }]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = rectify_anthropic_request(&mut body);
|
|
||||||
|
|
||||||
assert!(!result.applied);
|
|
||||||
assert_eq!(result.removed_thinking_blocks, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rectify_no_messages() {
|
|
||||||
let mut body = json!({ "model": "claude-test" });
|
|
||||||
let result = rectify_anthropic_request(&mut body);
|
|
||||||
assert!(!result.applied);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rectify_preserves_thinking_when_prefix_exists() {
|
|
||||||
let mut body = json!({
|
|
||||||
"model": "claude-test",
|
|
||||||
"thinking": { "type": "enabled" },
|
|
||||||
"messages": [{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{ "type": "thinking", "thinking": "some thought" },
|
|
||||||
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = rectify_anthropic_request(&mut body);
|
|
||||||
|
|
||||||
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
|
|
||||||
assert!(result.applied);
|
|
||||||
assert_eq!(result.removed_thinking_blocks, 1);
|
|
||||||
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
|
|
||||||
// 此时会触发删除顶层 thinking 的逻辑
|
|
||||||
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -191,67 +191,3 @@ pub struct AppProxyConfig {
|
|||||||
/// 计算错误率的最小请求数
|
/// 计算错误率的最小请求数
|
||||||
pub circuit_min_requests: u32,
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ pub mod proxy;
|
|||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod speedtest;
|
pub mod speedtest;
|
||||||
pub mod stream_check;
|
pub mod stream_check;
|
||||||
pub mod template;
|
|
||||||
pub mod usage_stats;
|
pub mod usage_stats;
|
||||||
|
|
||||||
pub use config::ConfigService;
|
pub use config::ConfigService;
|
||||||
@@ -20,12 +19,6 @@ pub use proxy::ProxyService;
|
|||||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use template::{
|
|
||||||
BatchInstallResult, ComponentDetail, ComponentMetadata, ComponentType, InstalledComponent,
|
|
||||||
MarketplaceBundle, MarketplaceBundleItem, PaginatedResult, TemplateComponent, TemplateRepo,
|
|
||||||
TemplateService,
|
|
||||||
};
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use usage_stats::{
|
pub use usage_stats::{
|
||||||
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
||||||
RequestLogDetail, UsageSummary,
|
RequestLogDetail, UsageSummary,
|
||||||
|
|||||||
@@ -36,13 +36,6 @@ pub struct StreamCheckConfig {
|
|||||||
pub codex_model: String,
|
pub codex_model: String,
|
||||||
/// Gemini 测试模型
|
/// Gemini 测试模型
|
||||||
pub gemini_model: String,
|
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 {
|
impl Default for StreamCheckConfig {
|
||||||
@@ -54,7 +47,6 @@ impl Default for StreamCheckConfig {
|
|||||||
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||||
codex_model: "gpt-5.1-codex@low".to_string(),
|
codex_model: "gpt-5.1-codex@low".to_string(),
|
||||||
gemini_model: "gemini-3-pro-preview".to_string(),
|
gemini_model: "gemini-3-pro-preview".to_string(),
|
||||||
test_prompt: default_test_prompt(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +110,7 @@ impl StreamCheckService {
|
|||||||
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
||||||
status: HealthStatus::Failed,
|
status: HealthStatus::Failed,
|
||||||
success: false,
|
success: false,
|
||||||
message: "Check failed".to_string(),
|
message: "检查失败".to_string(),
|
||||||
response_time_ms: None,
|
response_time_ms: None,
|
||||||
http_status: None,
|
http_status: None,
|
||||||
model_used: String::new(),
|
model_used: String::new(),
|
||||||
@@ -138,18 +130,17 @@ impl StreamCheckService {
|
|||||||
|
|
||||||
let base_url = adapter
|
let base_url = adapter
|
||||||
.extract_base_url(provider)
|
.extract_base_url(provider)
|
||||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
|
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||||
|
|
||||||
let auth = adapter
|
let auth = adapter
|
||||||
.extract_auth(provider)
|
.extract_auth(provider)
|
||||||
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||||
|
|
||||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||||
|
|
||||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||||
let test_prompt = &config.test_prompt;
|
|
||||||
|
|
||||||
let result = match app_type {
|
let result = match app_type {
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
@@ -158,21 +149,13 @@ impl StreamCheckService {
|
|||||||
&base_url,
|
&base_url,
|
||||||
&auth,
|
&auth,
|
||||||
&model_to_test,
|
&model_to_test,
|
||||||
test_prompt,
|
|
||||||
request_timeout,
|
request_timeout,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
AppType::Codex => {
|
AppType::Codex => {
|
||||||
Self::check_codex_stream(
|
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test, request_timeout)
|
||||||
&client,
|
.await
|
||||||
&base_url,
|
|
||||||
&auth,
|
|
||||||
&model_to_test,
|
|
||||||
test_prompt,
|
|
||||||
request_timeout,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
Self::check_gemini_stream(
|
Self::check_gemini_stream(
|
||||||
@@ -180,7 +163,6 @@ impl StreamCheckService {
|
|||||||
&base_url,
|
&base_url,
|
||||||
&auth,
|
&auth,
|
||||||
&model_to_test,
|
&model_to_test,
|
||||||
test_prompt,
|
|
||||||
request_timeout,
|
request_timeout,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -197,7 +179,7 @@ impl StreamCheckService {
|
|||||||
Ok(StreamCheckResult {
|
Ok(StreamCheckResult {
|
||||||
status: health_status,
|
status: health_status,
|
||||||
success: true,
|
success: true,
|
||||||
message: "Check succeeded".to_string(),
|
message: "检查成功".to_string(),
|
||||||
response_time_ms: Some(response_time),
|
response_time_ms: Some(response_time),
|
||||||
http_status: Some(status_code),
|
http_status: Some(status_code),
|
||||||
model_used: model,
|
model_used: model,
|
||||||
@@ -219,68 +201,32 @@ impl StreamCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Claude 流式检查
|
/// Claude 流式检查
|
||||||
///
|
|
||||||
/// 严格按照 Claude CLI 真实请求格式构建请求
|
|
||||||
async fn check_claude_stream(
|
async fn check_claude_stream(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &str,
|
||||||
test_prompt: &str,
|
|
||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
|
|
||||||
let url = if base.ends_with("/v1") {
|
let url = if base.ends_with("/v1") {
|
||||||
format!("{base}/messages?beta=true")
|
format!("{base}/messages")
|
||||||
} else {
|
} else {
|
||||||
format!("{base}/v1/messages?beta=true")
|
format!("{base}/v1/messages")
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 1,
|
"max_tokens": 1,
|
||||||
"messages": [{ "role": "user", "content": test_prompt }],
|
"messages": [{ "role": "user", "content": "hi" }],
|
||||||
"stream": true
|
"stream": true
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取本地系统信息
|
|
||||||
let os_name = Self::get_os_name();
|
|
||||||
let arch_name = Self::get_arch_name();
|
|
||||||
|
|
||||||
// 严格按照 Claude CLI 请求格式设置 headers
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
// 认证 headers(双重认证)
|
|
||||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
|
||||||
.header("x-api-key", &auth.api_key)
|
.header("x-api-key", &auth.api_key)
|
||||||
// Anthropic 必需 headers
|
|
||||||
.header("anthropic-version", "2023-06-01")
|
.header("anthropic-version", "2023-06-01")
|
||||||
.header(
|
.header("Content-Type", "application/json")
|
||||||
"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)
|
.timeout(timeout)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
@@ -299,63 +245,52 @@ impl StreamCheckService {
|
|||||||
if let Some(chunk) = stream.next().await {
|
if let Some(chunk) = stream.next().await {
|
||||||
match chunk {
|
match chunk {
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Message("No response data received".to_string()))
|
Err(AppError::Message("未收到响应数据".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codex 流式检查
|
/// Codex 流式检查
|
||||||
///
|
|
||||||
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
|
|
||||||
async fn check_codex_stream(
|
async fn check_codex_stream(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &str,
|
||||||
test_prompt: &str,
|
|
||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
|
|
||||||
let url = if base.ends_with("/v1") {
|
let url = if base.ends_with("/v1") {
|
||||||
format!("{base}/responses")
|
format!("{base}/chat/completions")
|
||||||
} else {
|
} else {
|
||||||
format!("{base}/v1/responses")
|
format!("{base}/v1/chat/completions")
|
||||||
};
|
};
|
||||||
|
|
||||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
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!({
|
let mut body = json!({
|
||||||
"model": actual_model,
|
"model": actual_model,
|
||||||
"input": [{ "role": "user", "content": test_prompt }],
|
"messages": [
|
||||||
|
{ "role": "system", "content": "" },
|
||||||
|
{ "role": "assistant", "content": "" },
|
||||||
|
{ "role": "user", "content": "hi" }
|
||||||
|
],
|
||||||
|
"max_tokens": 1,
|
||||||
|
"temperature": 0,
|
||||||
"stream": true
|
"stream": true
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果是推理模型,添加 reasoning_effort
|
// 如果是推理模型,添加 reasoning_effort
|
||||||
if let Some(effort) = reasoning_effort {
|
if let Some(effort) = reasoning_effort {
|
||||||
body["reasoning"] = json!({ "effort": effort });
|
body["reasoning_effort"] = json!(effort);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 严格按照 Codex CLI 请求格式设置 headers
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
.header("content-type", "application/json")
|
.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)
|
.timeout(timeout)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
@@ -373,10 +308,10 @@ impl StreamCheckService {
|
|||||||
if let Some(chunk) = stream.next().await {
|
if let Some(chunk) = stream.next().await {
|
||||||
match chunk {
|
match chunk {
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Message("No response data received".to_string()))
|
Err(AppError::Message("未收到响应数据".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +321,6 @@ impl StreamCheckService {
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &str,
|
||||||
test_prompt: &str,
|
|
||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
@@ -394,7 +328,7 @@ impl StreamCheckService {
|
|||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [{ "role": "user", "content": test_prompt }],
|
"messages": [{ "role": "user", "content": "hi" }],
|
||||||
"max_tokens": 1,
|
"max_tokens": 1,
|
||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
"stream": true
|
"stream": true
|
||||||
@@ -421,10 +355,10 @@ impl StreamCheckService {
|
|||||||
if let Some(chunk) = stream.next().await {
|
if let Some(chunk) = stream.next().await {
|
||||||
match chunk {
|
match chunk {
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Message("No response data received".to_string()))
|
Err(AppError::Message("未收到响应数据".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,6 +373,7 @@ impl StreamCheckService {
|
|||||||
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||||
/// 返回 (实际模型名, Option<推理等级>)
|
/// 返回 (实际模型名, Option<推理等级>)
|
||||||
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
||||||
|
// 查找 @ 或 # 分隔符
|
||||||
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
||||||
let actual_model = model[..pos].to_string();
|
let actual_model = model[..pos].to_string();
|
||||||
let effort = model[pos + 1..].to_string();
|
let effort = model[pos + 1..].to_string();
|
||||||
@@ -451,14 +386,17 @@ impl StreamCheckService {
|
|||||||
|
|
||||||
fn should_retry(msg: &str) -> bool {
|
fn should_retry(msg: &str) -> bool {
|
||||||
let lower = msg.to_lowercase();
|
let lower = msg.to_lowercase();
|
||||||
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
|
lower.contains("timeout")
|
||||||
|
|| lower.contains("abort")
|
||||||
|
|| lower.contains("中断")
|
||||||
|
|| lower.contains("超时")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_request_error(e: reqwest::Error) -> AppError {
|
fn map_request_error(e: reqwest::Error) -> AppError {
|
||||||
if e.is_timeout() {
|
if e.is_timeout() {
|
||||||
AppError::Message("Request timeout".to_string())
|
AppError::Message("请求超时".to_string())
|
||||||
} else if e.is_connect() {
|
} else if e.is_connect() {
|
||||||
AppError::Message(format!("Connection failed: {e}"))
|
AppError::Message(format!("连接失败: {e}"))
|
||||||
} else {
|
} else {
|
||||||
AppError::Message(e.to_string())
|
AppError::Message(e.to_string())
|
||||||
}
|
}
|
||||||
@@ -505,26 +443,6 @@ impl StreamCheckService {
|
|||||||
.map(|m| m.as_str().trim().to_string())
|
.map(|m| m.as_str().trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.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)]
|
#[cfg(test)]
|
||||||
@@ -549,10 +467,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_retry() {
|
fn test_should_retry() {
|
||||||
assert!(StreamCheckService::should_retry("Request timeout"));
|
assert!(StreamCheckService::should_retry("请求超时"));
|
||||||
assert!(StreamCheckService::should_retry("request timed out"));
|
assert!(StreamCheckService::should_retry("request timeout"));
|
||||||
assert!(StreamCheckService::should_retry("connection abort"));
|
assert!(!StreamCheckService::should_retry("API Key 无效"));
|
||||||
assert!(!StreamCheckService::should_retry("API Key invalid"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -580,33 +497,4 @@ mod tests {
|
|||||||
assert_eq!(model, "gpt-4o-mini");
|
assert_eq!(model, "gpt-4o-mini");
|
||||||
assert_eq!(effort, None);
|
assert_eq!(effort, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_os_name() {
|
|
||||||
let os_name = StreamCheckService::get_os_name();
|
|
||||||
// 确保返回非空字符串
|
|
||||||
assert!(!os_name.is_empty());
|
|
||||||
// 在 macOS 上应该返回 "MacOS"
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
assert_eq!(os_name, "MacOS");
|
|
||||||
// 在 Linux 上应该返回 "Linux"
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
assert_eq!(os_name, "Linux");
|
|
||||||
// 在 Windows 上应该返回 "Windows"
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
assert_eq!(os_name, "Windows");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_arch_name() {
|
|
||||||
let arch_name = StreamCheckService::get_arch_name();
|
|
||||||
// 确保返回非空字符串
|
|
||||||
assert!(!arch_name.is_empty());
|
|
||||||
// 在 ARM64 上应该返回 "arm64"
|
|
||||||
#[cfg(target_arch = "aarch64")]
|
|
||||||
assert_eq!(arch_name, "arm64");
|
|
||||||
// 在 x86_64 上应该返回 "x86_64"
|
|
||||||
#[cfg(target_arch = "x86_64")]
|
|
||||||
assert_eq!(arch_name, "x86_64");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
//! Claude 应用适配器
|
|
||||||
//!
|
|
||||||
//! 完整支持所有组件类型:
|
|
||||||
//! - Agent → `~/.claude/agents/{name}.md`
|
|
||||||
//! - Command → `~/.claude/commands/{name}.md`
|
|
||||||
//! - MCP → 合并到 `~/.claude.json` 的 mcpServers 字段
|
|
||||||
//! - Setting → 合并到 `~/.claude/settings.json` 的 permissions 字段
|
|
||||||
//! - Hook → 合并到 `~/.claude/settings.json` 的 hooks 字段
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::AppAdapter;
|
|
||||||
use crate::config::{atomic_write, get_claude_config_dir, get_claude_mcp_path};
|
|
||||||
|
|
||||||
/// Claude 应用适配器
|
|
||||||
pub struct ClaudeAdapter {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClaudeAdapter {
|
|
||||||
/// 创建新的 Claude 适配器实例
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
config_dir: get_claude_config_dir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
|
||||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(serde_json::json!({}));
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
|
||||||
let value: Value = serde_json::from_str(&content)
|
|
||||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 JSON 配置文件(原子写入)
|
|
||||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
|
||||||
|
|
||||||
atomic_write(path, json.as_bytes())
|
|
||||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 合并两个 JSON 对象(深度合并)
|
|
||||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
|
||||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
|
||||||
for (key, value) in overlay_obj {
|
|
||||||
if let Some(base_value) = base_obj.get_mut(key) {
|
|
||||||
// 如果两边都是对象,递归合并
|
|
||||||
if base_value.is_object() && value.is_object() {
|
|
||||||
Self::merge_json(base_value, value);
|
|
||||||
} else {
|
|
||||||
// 否则直接覆盖
|
|
||||||
*base_value = value.clone();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 键不存在,直接插入
|
|
||||||
base_obj.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Markdown 文件(通用)
|
|
||||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
let dir = self.config_dir.join(subdir);
|
|
||||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
|
||||||
|
|
||||||
let filename = if name.ends_with(".md") {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{name}.md")
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = dir.join(&filename);
|
|
||||||
|
|
||||||
atomic_write(&file_path, content.as_bytes())
|
|
||||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude {}: {}", subdir, file_path.display());
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Claude settings.json 路径
|
|
||||||
fn get_settings_path(&self) -> PathBuf {
|
|
||||||
crate::config::get_claude_settings_path()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppAdapter for ClaudeAdapter {
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "agents", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "commands", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
|
||||||
let mcp_path = get_claude_mcp_path();
|
|
||||||
|
|
||||||
// 读取现有 MCP 配置
|
|
||||||
let mut current = Self::read_json_file(&mcp_path)?;
|
|
||||||
|
|
||||||
// 确保 mcpServers 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("mcpServers").is_none() {
|
|
||||||
current["mcpServers"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并新的 MCP 服务器配置
|
|
||||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
|
||||||
Self::merge_json(mcp_servers, mcp_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&mcp_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude MCP 配置到: {}", mcp_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_setting(&self, setting_config: &Value) -> Result<()> {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
|
|
||||||
// 读取现有配置
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 确保 permissions 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("permissions").is_none() {
|
|
||||||
current["permissions"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并新的 permissions 配置
|
|
||||||
if let Some(permissions) = current.get_mut("permissions") {
|
|
||||||
Self::merge_json(permissions, setting_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude Setting 配置到: {}", settings_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_hook(&self, hook_config: &Value) -> Result<()> {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
|
|
||||||
// 读取现有配置
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 确保 hooks 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("hooks").is_none() {
|
|
||||||
current["hooks"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并新的 hooks 配置
|
|
||||||
if let Some(hooks) = current.get_mut("hooks") {
|
|
||||||
Self::merge_json(hooks, hook_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude Hook 配置到: {}", settings_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
|
||||||
match component_type.to_lowercase().as_str() {
|
|
||||||
"agent" => {
|
|
||||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Claude Agent: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"command" => {
|
|
||||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Claude Command: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let mcp_path = get_claude_mcp_path();
|
|
||||||
let mut current = Self::read_json_file(&mcp_path)?;
|
|
||||||
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcpServers")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
mcp_servers.remove(name);
|
|
||||||
Self::write_json_file(&mcp_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Claude MCP 服务器: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"setting" => {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
if let Some(permissions) = current
|
|
||||||
.get_mut("permissions")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
permissions.remove(name);
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Claude Setting: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"hook" => {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
if let Some(hooks) = current.get_mut("hooks").and_then(|v| v.as_object_mut()) {
|
|
||||||
hooks.remove(name);
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Claude Hook: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("不支持的组件类型: {component_type}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_dir(&self) -> PathBuf {
|
|
||||||
self.config_dir.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
component_type.to_lowercase().as_str(),
|
|
||||||
"agent" | "command" | "mcp" | "setting" | "hook"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ClaudeAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
//! Codex 应用适配器
|
|
||||||
//!
|
|
||||||
//! 部分支持:
|
|
||||||
//! - Agent → `~/.codex/agents/{name}.md`
|
|
||||||
//! - Command → `~/.codex/commands/{name}.md`
|
|
||||||
//! - MCP → 合并到 `~/.codex/config.toml` 的 [mcp_servers] 表
|
|
||||||
//! - Setting/Hook → 不支持(Codex 不支持这些功能)
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::AppAdapter;
|
|
||||||
use crate::codex_config::get_codex_config_dir;
|
|
||||||
use crate::config::{atomic_write, write_text_file};
|
|
||||||
|
|
||||||
/// Codex 应用适配器
|
|
||||||
pub struct CodexAdapter {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CodexAdapter {
|
|
||||||
/// 创建新的 Codex 适配器实例
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
config_dir: get_codex_config_dir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Markdown 文件(通用)
|
|
||||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
let dir = self.config_dir.join(subdir);
|
|
||||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
|
||||||
|
|
||||||
let filename = if name.ends_with(".md") {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{name}.md")
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = dir.join(&filename);
|
|
||||||
|
|
||||||
atomic_write(&file_path, content.as_bytes())
|
|
||||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
|
||||||
|
|
||||||
log::info!("已安装 Codex {}: {}", subdir, file_path.display());
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Codex config.toml 路径
|
|
||||||
fn get_config_toml_path(&self) -> PathBuf {
|
|
||||||
crate::codex_config::get_codex_config_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 TOML 配置文件
|
|
||||||
fn read_toml_file(path: &PathBuf) -> Result<toml::Table> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(toml::Table::new());
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
|
||||||
let table: toml::Table = toml::from_str(&content)
|
|
||||||
.with_context(|| format!("解析 TOML 失败: {}", path.display()))?;
|
|
||||||
Ok(table)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 TOML 配置文件(原子写入)
|
|
||||||
fn write_toml_file(path: &Path, table: &toml::Table) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let toml_string = toml::to_string_pretty(table).context("序列化 TOML 失败")?;
|
|
||||||
|
|
||||||
write_text_file(path, &toml_string)
|
|
||||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 JSON MCP 配置转换为 TOML 格式
|
|
||||||
fn json_mcp_to_toml(json_config: &Value) -> Result<toml::Table> {
|
|
||||||
let mut mcp_servers = toml::Table::new();
|
|
||||||
|
|
||||||
if let Some(obj) = json_config.as_object() {
|
|
||||||
for (server_id, server_spec) in obj {
|
|
||||||
let mut server_table = toml::Table::new();
|
|
||||||
|
|
||||||
if let Some(spec_obj) = server_spec.as_object() {
|
|
||||||
// type 字段(默认 stdio)
|
|
||||||
let server_type = spec_obj
|
|
||||||
.get("type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("stdio");
|
|
||||||
server_table.insert(
|
|
||||||
"type".to_string(),
|
|
||||||
toml::Value::String(server_type.to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
match server_type {
|
|
||||||
"stdio" => {
|
|
||||||
// command 字段(必需)
|
|
||||||
if let Some(cmd) = spec_obj.get("command").and_then(|v| v.as_str()) {
|
|
||||||
server_table.insert(
|
|
||||||
"command".to_string(),
|
|
||||||
toml::Value::String(cmd.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// args 字段(可选)
|
|
||||||
if let Some(args) = spec_obj.get("args").and_then(|v| v.as_array()) {
|
|
||||||
let toml_args: Vec<toml::Value> = args
|
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str())
|
|
||||||
.map(|s| toml::Value::String(s.to_string()))
|
|
||||||
.collect();
|
|
||||||
if !toml_args.is_empty() {
|
|
||||||
server_table
|
|
||||||
.insert("args".to_string(), toml::Value::Array(toml_args));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// env 字段(可选)
|
|
||||||
if let Some(env) = spec_obj.get("env").and_then(|v| v.as_object()) {
|
|
||||||
let mut env_table = toml::Table::new();
|
|
||||||
for (key, value) in env {
|
|
||||||
if let Some(val_str) = value.as_str() {
|
|
||||||
env_table.insert(
|
|
||||||
key.clone(),
|
|
||||||
toml::Value::String(val_str.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !env_table.is_empty() {
|
|
||||||
server_table
|
|
||||||
.insert("env".to_string(), toml::Value::Table(env_table));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cwd 字段(可选)
|
|
||||||
if let Some(cwd) = spec_obj.get("cwd").and_then(|v| v.as_str()) {
|
|
||||||
server_table.insert(
|
|
||||||
"cwd".to_string(),
|
|
||||||
toml::Value::String(cwd.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"http" | "sse" => {
|
|
||||||
// url 字段(必需)
|
|
||||||
if let Some(url) = spec_obj.get("url").and_then(|v| v.as_str()) {
|
|
||||||
server_table.insert(
|
|
||||||
"url".to_string(),
|
|
||||||
toml::Value::String(url.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// http_headers 字段(可选)
|
|
||||||
if let Some(headers) =
|
|
||||||
spec_obj.get("http_headers").and_then(|v| v.as_object())
|
|
||||||
{
|
|
||||||
let mut headers_table = toml::Table::new();
|
|
||||||
for (key, value) in headers {
|
|
||||||
if let Some(val_str) = value.as_str() {
|
|
||||||
headers_table.insert(
|
|
||||||
key.clone(),
|
|
||||||
toml::Value::String(val_str.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !headers_table.is_empty() {
|
|
||||||
server_table.insert(
|
|
||||||
"http_headers".to_string(),
|
|
||||||
toml::Value::Table(headers_table),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mcp_servers.insert(server_id.clone(), toml::Value::Table(server_table));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(mcp_servers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppAdapter for CodexAdapter {
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "agents", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "commands", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
|
||||||
let config_path = self.get_config_toml_path();
|
|
||||||
|
|
||||||
// 读取现有 TOML 配置
|
|
||||||
let mut current = Self::read_toml_file(&config_path)?;
|
|
||||||
|
|
||||||
// 确保 mcp_servers 表存在
|
|
||||||
if !current.contains_key("mcp_servers") {
|
|
||||||
current.insert(
|
|
||||||
"mcp_servers".to_string(),
|
|
||||||
toml::Value::Table(toml::Table::new()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 JSON MCP 配置到 TOML
|
|
||||||
let new_mcp_servers = Self::json_mcp_to_toml(mcp_config)?;
|
|
||||||
|
|
||||||
// 合并 MCP 服务器配置
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcp_servers")
|
|
||||||
.and_then(|v| v.as_table_mut())
|
|
||||||
{
|
|
||||||
for (server_id, server_config) in new_mcp_servers {
|
|
||||||
mcp_servers.insert(server_id, server_config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_toml_file(&config_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Codex MCP 配置到: {}", config_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
|
||||||
bail!("Codex 不支持 Setting 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
|
||||||
bail!("Codex 不支持 Hook 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
|
||||||
match component_type.to_lowercase().as_str() {
|
|
||||||
"agent" => {
|
|
||||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Codex Agent: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"command" => {
|
|
||||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Codex Command: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let config_path = self.get_config_toml_path();
|
|
||||||
let mut current = Self::read_toml_file(&config_path)?;
|
|
||||||
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcp_servers")
|
|
||||||
.and_then(|v| v.as_table_mut())
|
|
||||||
{
|
|
||||||
mcp_servers.remove(name);
|
|
||||||
Self::write_toml_file(&config_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Codex MCP 服务器: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"setting" | "hook" => {
|
|
||||||
bail!("Codex 不支持 {component_type} 组件类型")
|
|
||||||
}
|
|
||||||
_ => bail!("不支持的组件类型: {component_type}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_dir(&self) -> PathBuf {
|
|
||||||
self.config_dir.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
component_type.to_lowercase().as_str(),
|
|
||||||
"agent" | "command" | "mcp"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CodexAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
//! Gemini 应用适配器
|
|
||||||
//!
|
|
||||||
//! 部分支持:
|
|
||||||
//! - Agent → `~/.gemini/agents/{name}.md`
|
|
||||||
//! - Command → `~/.gemini/commands/{name}.md`
|
|
||||||
//! - MCP → 合并到 `~/.gemini/settings.json` 的 mcpServers 字段
|
|
||||||
//! - Setting/Hook → 不支持(Gemini 不支持这些功能)
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::AppAdapter;
|
|
||||||
use crate::config::atomic_write;
|
|
||||||
use crate::gemini_config::{get_gemini_dir, get_gemini_settings_path};
|
|
||||||
|
|
||||||
/// Gemini 应用适配器
|
|
||||||
pub struct GeminiAdapter {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GeminiAdapter {
|
|
||||||
/// 创建新的 Gemini 适配器实例
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
config_dir: get_gemini_dir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
|
||||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(serde_json::json!({}));
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
|
||||||
let value: Value = serde_json::from_str(&content)
|
|
||||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 JSON 配置文件(原子写入)
|
|
||||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
|
||||||
|
|
||||||
atomic_write(path, json.as_bytes())
|
|
||||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 合并两个 JSON 对象(深度合并)
|
|
||||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
|
||||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
|
||||||
for (key, value) in overlay_obj {
|
|
||||||
if let Some(base_value) = base_obj.get_mut(key) {
|
|
||||||
// 如果两边都是对象,递归合并
|
|
||||||
if base_value.is_object() && value.is_object() {
|
|
||||||
Self::merge_json(base_value, value);
|
|
||||||
} else {
|
|
||||||
// 否则直接覆盖
|
|
||||||
*base_value = value.clone();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 键不存在,直接插入
|
|
||||||
base_obj.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Markdown 文件(通用)
|
|
||||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
let dir = self.config_dir.join(subdir);
|
|
||||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
|
||||||
|
|
||||||
let filename = if name.ends_with(".md") {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{name}.md")
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = dir.join(&filename);
|
|
||||||
|
|
||||||
atomic_write(&file_path, content.as_bytes())
|
|
||||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
|
||||||
|
|
||||||
log::info!("已安装 Gemini {}: {}", subdir, file_path.display());
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Gemini settings.json 路径
|
|
||||||
fn get_settings_path(&self) -> PathBuf {
|
|
||||||
get_gemini_settings_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换 MCP 配置为 Gemini 格式
|
|
||||||
///
|
|
||||||
/// Gemini 使用特殊格式:
|
|
||||||
/// - HTTP 类型:使用 `httpUrl` 而不是 `url` + `type: "http"`
|
|
||||||
/// - SSE/stdio 类型:保持标准格式
|
|
||||||
fn transform_mcp_to_gemini(mcp_config: &Value) -> Result<Value> {
|
|
||||||
let mut transformed = mcp_config.clone();
|
|
||||||
|
|
||||||
if let Some(obj) = transformed.as_object_mut() {
|
|
||||||
for (_server_id, server_spec) in obj.iter_mut() {
|
|
||||||
if let Some(spec_obj) = server_spec.as_object_mut() {
|
|
||||||
// 检查是否为 HTTP 类型
|
|
||||||
let is_http = spec_obj
|
|
||||||
.get("type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|t| t == "http")
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if is_http {
|
|
||||||
// 将 url 字段转换为 httpUrl
|
|
||||||
if let Some(url) = spec_obj.remove("url") {
|
|
||||||
spec_obj.insert("httpUrl".to_string(), url);
|
|
||||||
}
|
|
||||||
// 移除 type 字段(Gemini 不需要显式指定 type)
|
|
||||||
spec_obj.remove("type");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(transformed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppAdapter for GeminiAdapter {
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "agents", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "commands", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
|
|
||||||
// 读取现有配置
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 确保 mcpServers 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("mcpServers").is_none() {
|
|
||||||
current["mcpServers"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 MCP 配置为 Gemini 格式
|
|
||||||
let transformed = Self::transform_mcp_to_gemini(mcp_config)?;
|
|
||||||
|
|
||||||
// 合并新的 MCP 服务器配置
|
|
||||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
|
||||||
Self::merge_json(mcp_servers, &transformed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Gemini MCP 配置到: {}", settings_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
|
||||||
bail!("Gemini 不支持 Setting 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
|
||||||
bail!("Gemini 不支持 Hook 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
|
||||||
match component_type.to_lowercase().as_str() {
|
|
||||||
"agent" => {
|
|
||||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Gemini Agent: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"command" => {
|
|
||||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Gemini Command: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcpServers")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
mcp_servers.remove(name);
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Gemini MCP 服务器: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"setting" | "hook" => {
|
|
||||||
bail!("Gemini 不支持 {component_type} 组件类型")
|
|
||||||
}
|
|
||||||
_ => bail!("不支持的组件类型: {component_type}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_dir(&self) -> PathBuf {
|
|
||||||
self.config_dir.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
component_type.to_lowercase().as_str(),
|
|
||||||
"agent" | "command" | "mcp"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GeminiAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
//! 应用适配器模块
|
|
||||||
//!
|
|
||||||
//! 负责将 Template 组件安装到不同应用的配置目录中。
|
|
||||||
//! 每个应用有独立的适配器实现,处理各自的配置格式和目录结构。
|
|
||||||
|
|
||||||
mod claude;
|
|
||||||
mod codex;
|
|
||||||
mod gemini;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub use claude::ClaudeAdapter;
|
|
||||||
pub use codex::CodexAdapter;
|
|
||||||
pub use gemini::GeminiAdapter;
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
|
|
||||||
/// 应用适配器 trait
|
|
||||||
///
|
|
||||||
/// 定义了将 Template 组件安装到应用配置目录的统一接口。
|
|
||||||
/// 每个应用实现自己的适配器来处理特定的配置格式和目录结构。
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub trait AppAdapter: Send + Sync {
|
|
||||||
/// 安装 Agent 到应用配置目录
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `content`: Agent 内容(Markdown 格式)
|
|
||||||
/// - `name`: Agent 名称(用作文件名)
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 安装后的文件路径
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf>;
|
|
||||||
|
|
||||||
/// 安装 Command 到应用配置目录
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `content`: Command 内容(Markdown 格式)
|
|
||||||
/// - `name`: Command 名称(用作文件名)
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 安装后的文件路径
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf>;
|
|
||||||
|
|
||||||
/// 安装 MCP 服务器配置
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `mcp_config`: MCP 服务器配置(JSON 对象)
|
|
||||||
///
|
|
||||||
/// # 说明
|
|
||||||
/// 配置会合并到应用的 MCP 配置文件中,保留现有配置。
|
|
||||||
fn install_mcp(&self, mcp_config: &serde_json::Value) -> Result<()>;
|
|
||||||
|
|
||||||
/// 安装 Setting (permissions)
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `setting_config`: Setting 配置(JSON 对象)
|
|
||||||
///
|
|
||||||
/// # 说明
|
|
||||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 permissions 字段。
|
|
||||||
fn install_setting(&self, setting_config: &serde_json::Value) -> Result<()>;
|
|
||||||
|
|
||||||
/// 安装 Hook
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `hook_config`: Hook 配置(JSON 对象)
|
|
||||||
///
|
|
||||||
/// # 说明
|
|
||||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 hooks 字段。
|
|
||||||
fn install_hook(&self, hook_config: &serde_json::Value) -> Result<()>;
|
|
||||||
|
|
||||||
/// 卸载组件
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `component_type`: 组件类型(agent/command/mcp/setting/hook)
|
|
||||||
/// - `name`: 组件名称或 ID
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()>;
|
|
||||||
|
|
||||||
/// 获取配置目录路径
|
|
||||||
fn config_dir(&self) -> PathBuf;
|
|
||||||
|
|
||||||
/// 检查组件类型是否支持
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `component_type`: 组件类型字符串
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 如果应用支持该组件类型返回 true,否则返回 false
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建应用适配器工厂函数
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `app_type`: 应用类型
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 对应应用的适配器实例
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn create_adapter(app_type: &AppType) -> Box<dyn AppAdapter> {
|
|
||||||
match app_type {
|
|
||||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
|
||||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
|
||||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use tokio::time::timeout;
|
|
||||||
|
|
||||||
use super::{ComponentMetadata, ComponentType, TemplateComponent, TemplateRepo, TemplateService};
|
|
||||||
|
|
||||||
impl TemplateService {
|
|
||||||
/// 刷新所有启用仓库的组件索引
|
|
||||||
pub async fn refresh_index(&self, conn: &Connection) -> Result<()> {
|
|
||||||
// 获取所有启用的仓库
|
|
||||||
let repos = self.list_enabled_repos(conn)?;
|
|
||||||
|
|
||||||
if repos.is_empty() {
|
|
||||||
log::info!("没有启用的模板仓库");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("开始刷新 {} 个模板仓库", repos.len());
|
|
||||||
|
|
||||||
// 并行扫描所有仓库
|
|
||||||
let scan_tasks = repos.iter().map(|repo| self.scan_repo(repo));
|
|
||||||
let results: Vec<Result<Vec<TemplateComponent>>> =
|
|
||||||
futures::future::join_all(scan_tasks).await;
|
|
||||||
|
|
||||||
// 处理扫描结果
|
|
||||||
let mut total_components = 0;
|
|
||||||
for (repo, result) in repos.iter().zip(results.into_iter()) {
|
|
||||||
match result {
|
|
||||||
Ok(components) => {
|
|
||||||
log::info!(
|
|
||||||
"仓库 {}/{} 扫描到 {} 个组件",
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
components.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 保存到数据库
|
|
||||||
if let Err(e) = self.save_components(conn, &components) {
|
|
||||||
log::error!("保存组件到数据库失败: {e}");
|
|
||||||
} else {
|
|
||||||
total_components += components.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("扫描仓库 {}/{} 失败: {}", repo.owner, repo.name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("刷新完成,共索引 {total_components} 个组件");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描单个仓库
|
|
||||||
pub async fn scan_repo(&self, repo: &TemplateRepo) -> Result<Vec<TemplateComponent>> {
|
|
||||||
log::info!("开始扫描仓库: {}/{}", repo.owner, repo.name);
|
|
||||||
|
|
||||||
// 下载仓库(增加超时控制)
|
|
||||||
let temp_dir = timeout(
|
|
||||||
std::time::Duration::from_secs(120),
|
|
||||||
self.download_repo(repo),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow!("下载仓库超时: {}/{}", repo.owner, repo.name))??;
|
|
||||||
|
|
||||||
let mut components = Vec::new();
|
|
||||||
|
|
||||||
// 扫描不同类型的组件
|
|
||||||
self.scan_agents(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_commands(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_mcps(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_settings(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_hooks(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_skills(&temp_dir, repo, &mut components)?;
|
|
||||||
|
|
||||||
// 清理临时目录
|
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"仓库 {}/{} 扫描完成,找到 {} 个组件",
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
components.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(components)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载仓库 ZIP
|
|
||||||
async fn download_repo(&self, repo: &TemplateRepo) -> Result<PathBuf> {
|
|
||||||
let temp_dir = tempfile::tempdir().context("创建临时目录失败")?;
|
|
||||||
let temp_path = temp_dir.path().to_path_buf();
|
|
||||||
let _ = temp_dir.keep();
|
|
||||||
|
|
||||||
// 尝试多个分支
|
|
||||||
let branches = if repo.branch.is_empty() {
|
|
||||||
vec!["main", "master"]
|
|
||||||
} else {
|
|
||||||
vec![repo.branch.as_str(), "main", "master"]
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut last_error = None;
|
|
||||||
for branch in branches {
|
|
||||||
let url = format!(
|
|
||||||
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
|
||||||
repo.owner, repo.name, branch
|
|
||||||
);
|
|
||||||
|
|
||||||
log::debug!("尝试下载: {url}");
|
|
||||||
match self.download_and_extract(&url, &temp_path).await {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!("成功下载仓库: {}/{} ({})", repo.owner, repo.name, branch);
|
|
||||||
return Ok(temp_path);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::debug!("下载分支 {branch} 失败: {e}");
|
|
||||||
last_error = Some(e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(last_error.unwrap_or_else(|| anyhow!("所有分支下载失败")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载并解压 ZIP
|
|
||||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
|
||||||
// 下载 ZIP
|
|
||||||
let response = self.client().get(url).send().await?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("下载失败: HTTP {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = response.bytes().await?;
|
|
||||||
|
|
||||||
// 解压
|
|
||||||
let cursor = std::io::Cursor::new(bytes);
|
|
||||||
let mut archive = zip::ZipArchive::new(cursor)?;
|
|
||||||
|
|
||||||
// 获取根目录名称
|
|
||||||
let root_name = if !archive.is_empty() {
|
|
||||||
let first_file = archive.by_index(0)?;
|
|
||||||
let name = first_file.name();
|
|
||||||
name.split('/').next().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
return Err(anyhow!("空的压缩包"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 解压所有文件
|
|
||||||
for i in 0..archive.len() {
|
|
||||||
let mut file = archive.by_index(i)?;
|
|
||||||
let file_path = file.name();
|
|
||||||
|
|
||||||
// 跳过根目录,直接提取内容
|
|
||||||
let relative_path =
|
|
||||||
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
|
||||||
stripped
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if relative_path.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let outpath = dest.join(relative_path);
|
|
||||||
|
|
||||||
if file.is_dir() {
|
|
||||||
fs::create_dir_all(&outpath)?;
|
|
||||||
} else {
|
|
||||||
if let Some(parent) = outpath.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let mut outfile = fs::File::create(&outpath)?;
|
|
||||||
std::io::copy(&mut file, &mut outfile)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Agents
|
|
||||||
fn scan_agents(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
// 尝试多个可能的路径
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("agents"),
|
|
||||||
base_dir.join("src").join("agents"),
|
|
||||||
base_dir.join("components").join("agents"),
|
|
||||||
];
|
|
||||||
for agents_dir in paths {
|
|
||||||
if agents_dir.exists() {
|
|
||||||
self.scan_markdown_components(
|
|
||||||
&agents_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Agent,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Commands
|
|
||||||
fn scan_commands(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir
|
|
||||||
.join("cli-tool")
|
|
||||||
.join("components")
|
|
||||||
.join("commands"),
|
|
||||||
base_dir.join("src").join("commands"),
|
|
||||||
base_dir.join("components").join("commands"),
|
|
||||||
];
|
|
||||||
for commands_dir in paths {
|
|
||||||
if commands_dir.exists() {
|
|
||||||
self.scan_markdown_components(
|
|
||||||
&commands_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Command,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 MCPs
|
|
||||||
fn scan_mcps(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("mcps"),
|
|
||||||
base_dir.join("src").join("mcp"),
|
|
||||||
base_dir.join("components").join("mcps"),
|
|
||||||
];
|
|
||||||
for mcps_dir in paths {
|
|
||||||
if mcps_dir.exists() {
|
|
||||||
self.scan_json_components(
|
|
||||||
&mcps_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Mcp,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Settings
|
|
||||||
fn scan_settings(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir
|
|
||||||
.join("cli-tool")
|
|
||||||
.join("components")
|
|
||||||
.join("settings"),
|
|
||||||
base_dir.join("src").join("settings"),
|
|
||||||
base_dir.join("components").join("settings"),
|
|
||||||
];
|
|
||||||
for settings_dir in paths {
|
|
||||||
if settings_dir.exists() {
|
|
||||||
self.scan_json_components(
|
|
||||||
&settings_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Setting,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Hooks
|
|
||||||
fn scan_hooks(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("hooks"),
|
|
||||||
base_dir.join("src").join("hooks"),
|
|
||||||
base_dir.join("components").join("hooks"),
|
|
||||||
];
|
|
||||||
for hooks_dir in paths {
|
|
||||||
if hooks_dir.exists() {
|
|
||||||
self.scan_json_components(
|
|
||||||
&hooks_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Hook,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Skills
|
|
||||||
fn scan_skills(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("skills"),
|
|
||||||
base_dir.join("src").join("skills"),
|
|
||||||
base_dir.join("components").join("skills"),
|
|
||||||
];
|
|
||||||
for skills_dir in paths {
|
|
||||||
if skills_dir.exists() {
|
|
||||||
self.scan_skills_recursive(&skills_dir, base_dir, repo, components)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Markdown 组件(Agent/Command)
|
|
||||||
fn scan_markdown_components(
|
|
||||||
&self,
|
|
||||||
dir: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
|
|
||||||
if let Ok(component) =
|
|
||||||
self.parse_markdown_component(&path, base_dir, component_type.clone(), repo)
|
|
||||||
{
|
|
||||||
components.push(component);
|
|
||||||
}
|
|
||||||
} else if path.is_dir() {
|
|
||||||
// 递归扫描子目录(用于分类)
|
|
||||||
self.scan_markdown_components(
|
|
||||||
&path,
|
|
||||||
base_dir,
|
|
||||||
component_type.clone(),
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 JSON 组件(MCP/Setting/Hook)
|
|
||||||
fn scan_json_components(
|
|
||||||
&self,
|
|
||||||
dir: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
|
|
||||||
if let Ok(component) =
|
|
||||||
self.parse_json_component(&path, base_dir, component_type.clone(), repo)
|
|
||||||
{
|
|
||||||
components.push(component);
|
|
||||||
}
|
|
||||||
} else if path.is_dir() {
|
|
||||||
// 递归扫描子目录(用于分类)
|
|
||||||
self.scan_json_components(
|
|
||||||
&path,
|
|
||||||
base_dir,
|
|
||||||
component_type.clone(),
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归扫描技能目录
|
|
||||||
fn scan_skills_recursive(
|
|
||||||
&self,
|
|
||||||
current_dir: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let skill_md = current_dir.join("SKILL.md");
|
|
||||||
|
|
||||||
if skill_md.exists() {
|
|
||||||
// 发现技能
|
|
||||||
if let Ok(component) = self.parse_skill_component(&skill_md, base_dir, repo) {
|
|
||||||
components.push(component);
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 继续递归扫描子目录
|
|
||||||
for entry in fs::read_dir(current_dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
self.scan_skills_recursive(&path, base_dir, repo, components)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 Markdown 组件元数据
|
|
||||||
fn parse_markdown_component(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
) -> Result<TemplateComponent> {
|
|
||||||
let content = fs::read_to_string(path)?;
|
|
||||||
let meta = self.parse_component_metadata(&content)?;
|
|
||||||
|
|
||||||
let file_name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
// 提取分类(从目录结构)
|
|
||||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
|
||||||
|
|
||||||
// 计算相对于仓库根目录的路径
|
|
||||||
let relative_path = path
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.unwrap_or(path)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: None,
|
|
||||||
repo_id: repo.id.unwrap_or(0),
|
|
||||||
component_type,
|
|
||||||
category,
|
|
||||||
name: meta.name.unwrap_or_else(|| file_name.to_string()),
|
|
||||||
path: relative_path,
|
|
||||||
description: meta.description,
|
|
||||||
content_hash: Some(Self::calculate_hash(&content)),
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 JSON 组件元数据
|
|
||||||
fn parse_json_component(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
) -> Result<TemplateComponent> {
|
|
||||||
let content = fs::read_to_string(path)?;
|
|
||||||
let json: serde_json::Value = serde_json::from_str(&content)?;
|
|
||||||
|
|
||||||
let file_name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
let name = json
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or(file_name)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let description = json
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(String::from);
|
|
||||||
|
|
||||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
|
||||||
|
|
||||||
// 计算相对于仓库根目录的路径
|
|
||||||
let relative_path = path
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.unwrap_or(path)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: None,
|
|
||||||
repo_id: repo.id.unwrap_or(0),
|
|
||||||
component_type,
|
|
||||||
category,
|
|
||||||
name,
|
|
||||||
path: relative_path,
|
|
||||||
description,
|
|
||||||
content_hash: Some(Self::calculate_hash(&content)),
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析技能组件
|
|
||||||
fn parse_skill_component(
|
|
||||||
&self,
|
|
||||||
skill_md: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
) -> Result<TemplateComponent> {
|
|
||||||
let content = fs::read_to_string(skill_md)?;
|
|
||||||
let meta = self.parse_component_metadata(&content)?;
|
|
||||||
|
|
||||||
let skill_dir = skill_md.parent().unwrap();
|
|
||||||
let directory = skill_dir
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.unwrap_or(skill_dir)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: None,
|
|
||||||
repo_id: repo.id.unwrap_or(0),
|
|
||||||
component_type: ComponentType::Skill,
|
|
||||||
category: None,
|
|
||||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
|
||||||
path: directory,
|
|
||||||
description: meta.description,
|
|
||||||
content_hash: Some(Self::calculate_hash(&content)),
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析组件元数据(从 front matter)
|
|
||||||
pub fn parse_component_metadata(&self, content: &str) -> Result<ComponentMetadata> {
|
|
||||||
// 移除 BOM
|
|
||||||
let content = content.trim_start_matches('\u{feff}');
|
|
||||||
|
|
||||||
// 提取 YAML front matter
|
|
||||||
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
|
||||||
if parts.len() < 3 {
|
|
||||||
return Ok(ComponentMetadata {
|
|
||||||
name: None,
|
|
||||||
description: None,
|
|
||||||
tools: None,
|
|
||||||
model: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let front_matter = parts[1].trim();
|
|
||||||
let meta: ComponentMetadata =
|
|
||||||
serde_yaml::from_str(front_matter).unwrap_or(ComponentMetadata {
|
|
||||||
name: None,
|
|
||||||
description: None,
|
|
||||||
tools: None,
|
|
||||||
model: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提取分类(从路径)
|
|
||||||
fn extract_category(&self, path: &Path, base: &str) -> Option<String> {
|
|
||||||
let path_str = path.to_string_lossy();
|
|
||||||
if let Some(pos) = path_str.find(base) {
|
|
||||||
let after_base = &path_str[pos + base.len()..];
|
|
||||||
let parts: Vec<&str> = after_base.split('/').filter(|s| !s.is_empty()).collect();
|
|
||||||
if parts.len() > 1 {
|
|
||||||
return Some(parts[0].to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 计算内容哈希
|
|
||||||
fn calculate_hash(content: &str) -> String {
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(content.as_bytes());
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存组件到数据库
|
|
||||||
fn save_components(&self, conn: &Connection, components: &[TemplateComponent]) -> Result<()> {
|
|
||||||
for component in components {
|
|
||||||
// 检查是否已存在(通过 repo_id + component_type + path)
|
|
||||||
let existing: Option<i64> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM template_components
|
|
||||||
WHERE repo_id = ?1 AND component_type = ?2 AND path = ?3",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
&component.path
|
|
||||||
],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
if let Some(id) = existing {
|
|
||||||
// 更新现有组件
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_components
|
|
||||||
SET category = ?1, name = ?2, description = ?3, content_hash = ?4, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?5",
|
|
||||||
params![
|
|
||||||
&component.category,
|
|
||||||
&component.name,
|
|
||||||
&component.description,
|
|
||||||
&component.content_hash,
|
|
||||||
id
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
} else {
|
|
||||||
// 插入新组件
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_components (repo_id, component_type, category, name, path, description, content_hash)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
&component.category,
|
|
||||||
&component.name,
|
|
||||||
&component.path,
|
|
||||||
&component.description,
|
|
||||||
&component.content_hash
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出组件(支持过滤和分页)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn list_components(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
component_type: Option<ComponentType>,
|
|
||||||
category: Option<String>,
|
|
||||||
search: Option<String>,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
) -> Result<super::PaginatedResult<TemplateComponent>> {
|
|
||||||
let mut where_clauses = Vec::new();
|
|
||||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(ct) = &component_type {
|
|
||||||
where_clauses.push("component_type = ?");
|
|
||||||
params.push(Box::new(ct.as_str().to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cat) = &category {
|
|
||||||
where_clauses.push("category = ?");
|
|
||||||
params.push(Box::new(cat.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(s) = &search {
|
|
||||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
|
||||||
let search_pattern = format!("%{s}%");
|
|
||||||
params.push(Box::new(search_pattern.clone()));
|
|
||||||
params.push(Box::new(search_pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_sql = if where_clauses.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", where_clauses.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取总数
|
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
|
||||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
let total: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
|
|
||||||
|
|
||||||
// 获取分页数据
|
|
||||||
let offset = (page - 1) * page_size;
|
|
||||||
let query_sql = format!(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY name
|
|
||||||
LIMIT ? OFFSET ?"
|
|
||||||
);
|
|
||||||
|
|
||||||
params.push(Box::new(page_size as i64));
|
|
||||||
params.push(Box::new(offset as i64));
|
|
||||||
|
|
||||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&query_sql)?;
|
|
||||||
let components = stmt
|
|
||||||
.query_map(param_refs.as_slice(), |row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
|
|
||||||
Ok(super::PaginatedResult {
|
|
||||||
items: components,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
BatchInstallResult, ComponentDetail, ComponentType, InstalledComponent, TemplateComponent,
|
|
||||||
TemplateService,
|
|
||||||
};
|
|
||||||
|
|
||||||
impl TemplateService {
|
|
||||||
/// 获取组件详情(含完整内容)
|
|
||||||
pub async fn get_component(&self, conn: &Connection, id: i64) -> Result<ComponentDetail> {
|
|
||||||
// 查询组件基本信息
|
|
||||||
let component: TemplateComponent = conn.query_row(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 查询仓库信息
|
|
||||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
|
||||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
|
||||||
params![component.repo_id],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 构建 README URL
|
|
||||||
let readme_url = format!(
|
|
||||||
"https://github.com/{}/{}/tree/{}/{}",
|
|
||||||
repo_owner, repo_name, branch, component.path
|
|
||||||
);
|
|
||||||
|
|
||||||
// 下载并读取组件内容
|
|
||||||
let content = self
|
|
||||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(ComponentDetail {
|
|
||||||
component,
|
|
||||||
content,
|
|
||||||
repo_owner,
|
|
||||||
repo_name,
|
|
||||||
repo_branch: branch,
|
|
||||||
readme_url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载组件内容
|
|
||||||
async fn download_component_content(
|
|
||||||
&self,
|
|
||||||
owner: &str,
|
|
||||||
name: &str,
|
|
||||||
branch: &str,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
let url = format!("https://raw.githubusercontent.com/{owner}/{name}/{branch}/{path}");
|
|
||||||
|
|
||||||
let response = self.client().get(&url).send().await?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("下载组件内容失败: HTTP {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = response.text().await?;
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装组件到指定应用
|
|
||||||
pub async fn install_component(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
id: i64,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
// 获取组件详情
|
|
||||||
let detail = self.get_component(conn, id).await?;
|
|
||||||
|
|
||||||
// 根据组件类型执行不同的安装逻辑
|
|
||||||
match detail.component.component_type {
|
|
||||||
ComponentType::Agent => {
|
|
||||||
self.install_agent(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Command => {
|
|
||||||
self.install_command(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Mcp => {
|
|
||||||
self.install_mcp(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Setting => {
|
|
||||||
self.install_setting(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Hook => {
|
|
||||||
self.install_hook(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Skill => {
|
|
||||||
self.install_skill(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录安装状态
|
|
||||||
self.record_installation(conn, &detail.component, app_type)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Agent
|
|
||||||
async fn install_agent(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let agents_dir = config_dir.join("agents");
|
|
||||||
fs::create_dir_all(&agents_dir)?;
|
|
||||||
|
|
||||||
let file_name = format!("{}.md", detail.component.name);
|
|
||||||
let dest_path = agents_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!("Agent 已安装: {}", dest_path.display());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Command
|
|
||||||
async fn install_command(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let commands_dir = config_dir.join("commands");
|
|
||||||
fs::create_dir_all(&commands_dir)?;
|
|
||||||
|
|
||||||
let file_name = format!("{}.md", detail.component.name);
|
|
||||||
let dest_path = commands_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!("Command 已安装: {}", dest_path.display());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 MCP 服务器
|
|
||||||
/// MCP 配置保存为独立 JSON 文件到 mcps/ 目录,不会修改原有 .mcp.json
|
|
||||||
async fn install_mcp(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let mcps_dir = config_dir.join("mcps");
|
|
||||||
fs::create_dir_all(&mcps_dir)?;
|
|
||||||
|
|
||||||
// 保存为独立的 JSON 文件(保留原始格式,包含 mcpServers 结构)
|
|
||||||
let file_name = format!("{}.json", detail.component.name);
|
|
||||||
let dest_path = mcps_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!(
|
|
||||||
"MCP 配置已保存: {} (可手动合并到 .mcp.json)",
|
|
||||||
dest_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Setting
|
|
||||||
/// Setting 配置保存为独立 JSON 文件到 settings/ 目录,不会修改原有 settings.json
|
|
||||||
/// 原始格式包含 permissions 等配置,可手动合并
|
|
||||||
async fn install_setting(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let settings_dir = config_dir.join("settings");
|
|
||||||
fs::create_dir_all(&settings_dir)?;
|
|
||||||
|
|
||||||
// 保存为独立的 JSON 文件(保留原始格式,包含 permissions 等结构)
|
|
||||||
let file_name = format!("{}.json", detail.component.name);
|
|
||||||
let dest_path = settings_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!(
|
|
||||||
"Setting 配置已保存: {} (可手动合并到 settings.json)",
|
|
||||||
dest_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Hook
|
|
||||||
/// Hook 配置保存为独立 JSON 文件到 hooks/ 目录,不会修改原有 settings.json
|
|
||||||
/// 原始格式包含 hooks 对象(如 PostToolUse 等),可手动合并
|
|
||||||
async fn install_hook(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let hooks_dir = config_dir.join("hooks");
|
|
||||||
fs::create_dir_all(&hooks_dir)?;
|
|
||||||
|
|
||||||
// 保存为独立的 JSON 文件(保留原始格式,包含 hooks 结构)
|
|
||||||
let file_name = format!("{}.json", detail.component.name);
|
|
||||||
let dest_path = hooks_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!(
|
|
||||||
"Hook 配置已保存: {} (可手动合并到 settings.json 的 hooks 字段)",
|
|
||||||
dest_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Skill
|
|
||||||
/// Skill 是一个目录结构,包含 SKILL.md 和可能的子目录(如 reference/, scripts/)
|
|
||||||
/// 使用 GitHub API 递归下载整个目录
|
|
||||||
async fn install_skill(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let skills_dir = config_dir.join("skills");
|
|
||||||
fs::create_dir_all(&skills_dir)?;
|
|
||||||
|
|
||||||
let skill_dir = skills_dir.join(&detail.component.name);
|
|
||||||
fs::create_dir_all(&skill_dir)?;
|
|
||||||
|
|
||||||
// 首先保存 SKILL.md(已下载的内容)
|
|
||||||
let skill_md = skill_dir.join("SKILL.md");
|
|
||||||
fs::write(&skill_md, &detail.content)?;
|
|
||||||
|
|
||||||
// 尝试下载整个 skill 目录的其他文件
|
|
||||||
// 构建 GitHub API URL 来获取目录内容
|
|
||||||
let api_url = format!(
|
|
||||||
"https://api.github.com/repos/{}/{}/contents/{}",
|
|
||||||
detail.repo_owner,
|
|
||||||
detail.repo_name,
|
|
||||||
detail.component.path.trim_end_matches("/SKILL.md")
|
|
||||||
);
|
|
||||||
|
|
||||||
// 递归下载目录内容
|
|
||||||
if let Err(e) = self
|
|
||||||
.download_skill_directory(&api_url, &skill_dir, &detail.repo_branch)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::warn!("下载 Skill 附加文件失败: {e},仅安装 SKILL.md");
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Skill 已安装: {}", skill_dir.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归下载 Skill 目录内容
|
|
||||||
async fn download_skill_directory(
|
|
||||||
&self,
|
|
||||||
api_url: &str,
|
|
||||||
target_dir: &std::path::Path,
|
|
||||||
branch: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let response = self
|
|
||||||
.client()
|
|
||||||
.get(api_url)
|
|
||||||
.header("Accept", "application/vnd.github.v3+json")
|
|
||||||
.header("User-Agent", "cc-switch")
|
|
||||||
.query(&[("ref", branch)])
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("GitHub API 请求失败: {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let contents: Vec<serde_json::Value> = response.json().await?;
|
|
||||||
|
|
||||||
for item in contents {
|
|
||||||
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
let item_name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
|
|
||||||
// 跳过 SKILL.md(已经下载)
|
|
||||||
if item_name == "SKILL.md" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if item_type == "file" {
|
|
||||||
// 下载文件
|
|
||||||
if let Some(download_url) = item.get("download_url").and_then(|v| v.as_str()) {
|
|
||||||
let file_response = self.client().get(download_url).send().await?;
|
|
||||||
if file_response.status().is_success() {
|
|
||||||
let content = file_response.text().await?;
|
|
||||||
let file_path = target_dir.join(item_name);
|
|
||||||
fs::write(&file_path, &content)?;
|
|
||||||
log::debug!("下载文件: {}", file_path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if item_type == "dir" {
|
|
||||||
// 递归下载子目录
|
|
||||||
if let Some(sub_url) = item.get("url").and_then(|v| v.as_str()) {
|
|
||||||
let sub_dir = target_dir.join(item_name);
|
|
||||||
fs::create_dir_all(&sub_dir)?;
|
|
||||||
// 递归调用,使用 Box::pin 处理异步递归
|
|
||||||
Box::pin(self.download_skill_directory(sub_url, &sub_dir, branch)).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 记录安装状态
|
|
||||||
fn record_installation(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
component: &TemplateComponent,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO installed_components (component_id, component_type, name, path, app_type)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
||||||
params![
|
|
||||||
component.id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
&component.name,
|
|
||||||
&component.path,
|
|
||||||
app_type
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 卸载组件
|
|
||||||
pub fn uninstall_component(&self, conn: &Connection, id: i64, app_type: &str) -> Result<()> {
|
|
||||||
// 查询组件信息
|
|
||||||
let component: TemplateComponent = conn.query_row(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 删除文件
|
|
||||||
match component.component_type {
|
|
||||||
ComponentType::Agent => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("agents")
|
|
||||||
.join(format!("{}.md", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Command => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("commands")
|
|
||||||
.join(format!("{}.md", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Skill => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let skill_dir = config_dir.join("skills").join(&component.name);
|
|
||||||
if skill_dir.exists() {
|
|
||||||
fs::remove_dir_all(&skill_dir)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Mcp => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("mcps")
|
|
||||||
.join(format!("{}.json", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Setting => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("settings")
|
|
||||||
.join(format!("{}.json", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Hook => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("hooks")
|
|
||||||
.join(format!("{}.json", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除安装记录
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM installed_components
|
|
||||||
WHERE component_id = ?1 AND app_type = ?2",
|
|
||||||
params![id, app_type],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
log::info!("组件已卸载: {}", component.name);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量安装组件
|
|
||||||
pub async fn batch_install(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
ids: Vec<i64>,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<BatchInstallResult> {
|
|
||||||
let mut success = Vec::new();
|
|
||||||
let mut failed = Vec::new();
|
|
||||||
|
|
||||||
for id in ids {
|
|
||||||
match self.install_component(conn, id, app_type).await {
|
|
||||||
Ok(_) => success.push(id),
|
|
||||||
Err(e) => failed.push((id, e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(BatchInstallResult { success, failed })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出已安装的组件
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn list_installed(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
app_type: Option<&str>,
|
|
||||||
) -> Result<Vec<InstalledComponent>> {
|
|
||||||
let (sql, params): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type {
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
WHERE app_type = ?
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![Box::new(at.to_string())],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
|
||||||
let components = stmt
|
|
||||||
.query_map(param_refs.as_slice(), |row| {
|
|
||||||
Ok(InstalledComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
component_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
name: row.get(3)?,
|
|
||||||
path: row.get(4)?,
|
|
||||||
app_type: row.get(5)?,
|
|
||||||
installed_at: row
|
|
||||||
.get::<_, String>(6)?
|
|
||||||
.parse()
|
|
||||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
|
|
||||||
Ok(components)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 预览组件内容(仅获取内容,不进行安装)
|
|
||||||
pub async fn preview_content(&self, conn: &Connection, id: i64) -> Result<String> {
|
|
||||||
// 查询组件基本信息
|
|
||||||
let component: TemplateComponent = conn.query_row(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 查询仓库信息
|
|
||||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
|
||||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
|
||||||
params![component.repo_id],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 下载并读取组件内容
|
|
||||||
let content = self
|
|
||||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub mod adapters;
|
|
||||||
pub mod index;
|
|
||||||
pub mod installer;
|
|
||||||
pub mod repo;
|
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use adapters::{create_adapter, AppAdapter};
|
|
||||||
|
|
||||||
/// 组件类型枚举
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum ComponentType {
|
|
||||||
Agent,
|
|
||||||
Command,
|
|
||||||
Mcp,
|
|
||||||
Setting,
|
|
||||||
Hook,
|
|
||||||
Skill,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ComponentType {
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
ComponentType::Agent => "agent",
|
|
||||||
ComponentType::Command => "command",
|
|
||||||
ComponentType::Mcp => "mcp",
|
|
||||||
ComponentType::Setting => "setting",
|
|
||||||
ComponentType::Hook => "hook",
|
|
||||||
ComponentType::Skill => "skill",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"agent" => Some(ComponentType::Agent),
|
|
||||||
"command" => Some(ComponentType::Command),
|
|
||||||
"mcp" => Some(ComponentType::Mcp),
|
|
||||||
"setting" => Some(ComponentType::Setting),
|
|
||||||
"hook" => Some(ComponentType::Hook),
|
|
||||||
"skill" => Some(ComponentType::Skill),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模板仓库
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TemplateRepo {
|
|
||||||
pub id: Option<i64>,
|
|
||||||
pub owner: String,
|
|
||||||
pub name: String,
|
|
||||||
pub branch: String,
|
|
||||||
pub enabled: bool,
|
|
||||||
#[serde(rename = "createdAt")]
|
|
||||||
pub created_at: Option<DateTime<Utc>>,
|
|
||||||
#[serde(rename = "updatedAt")]
|
|
||||||
pub updated_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TemplateRepo {
|
|
||||||
pub fn new(owner: String, name: String, branch: String) -> Self {
|
|
||||||
Self {
|
|
||||||
id: None,
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
branch,
|
|
||||||
enabled: true,
|
|
||||||
created_at: None,
|
|
||||||
updated_at: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模板组件
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TemplateComponent {
|
|
||||||
pub id: Option<i64>,
|
|
||||||
#[serde(rename = "repoId")]
|
|
||||||
pub repo_id: i64,
|
|
||||||
#[serde(rename = "componentType")]
|
|
||||||
pub component_type: ComponentType,
|
|
||||||
pub category: Option<String>,
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(rename = "contentHash")]
|
|
||||||
pub content_hash: Option<String>,
|
|
||||||
/// 是否已安装(前端展示用,需要在查询时填充)
|
|
||||||
pub installed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 组件详情(含完整内容)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ComponentDetail {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub component: TemplateComponent,
|
|
||||||
/// 完整文件内容
|
|
||||||
pub content: String,
|
|
||||||
/// 仓库所有者
|
|
||||||
#[serde(rename = "repoOwner")]
|
|
||||||
pub repo_owner: String,
|
|
||||||
/// 仓库名称
|
|
||||||
#[serde(rename = "repoName")]
|
|
||||||
pub repo_name: String,
|
|
||||||
/// 仓库分支
|
|
||||||
#[serde(rename = "repoBranch")]
|
|
||||||
pub repo_branch: String,
|
|
||||||
/// GitHub README URL
|
|
||||||
#[serde(rename = "readmeUrl")]
|
|
||||||
pub readme_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 组件元数据(从文件 front matter 解析)
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ComponentMetadata {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
/// Agent 专用 - 工具列表
|
|
||||||
pub tools: Option<String>,
|
|
||||||
/// Agent 专用 - 模型名称
|
|
||||||
pub model: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 分页结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PaginatedResult<T> {
|
|
||||||
pub items: Vec<T>,
|
|
||||||
pub total: i64,
|
|
||||||
pub page: u32,
|
|
||||||
#[serde(rename = "pageSize")]
|
|
||||||
pub page_size: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量安装结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BatchInstallResult {
|
|
||||||
pub success: Vec<i64>,
|
|
||||||
pub failed: Vec<(i64, String)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 已安装组件
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct InstalledComponent {
|
|
||||||
pub id: Option<i64>,
|
|
||||||
#[serde(rename = "componentId")]
|
|
||||||
pub component_id: Option<i64>,
|
|
||||||
#[serde(rename = "componentType")]
|
|
||||||
pub component_type: ComponentType,
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
#[serde(rename = "appType")]
|
|
||||||
pub app_type: String,
|
|
||||||
#[serde(rename = "installedAt")]
|
|
||||||
pub installed_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 市场组合项(plugin 中的单个组件)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MarketplaceBundleItem {
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
#[serde(rename = "componentType")]
|
|
||||||
pub component_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 市场组合
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MarketplaceBundle {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub category: String,
|
|
||||||
pub components: Vec<MarketplaceBundleItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Template 服务
|
|
||||||
pub struct TemplateService {
|
|
||||||
http_client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TemplateService {
|
|
||||||
pub fn new() -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
http_client: Client::builder()
|
|
||||||
.user_agent("cc-switch")
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.context("创建 HTTP 客户端失败")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 HTTP 客户端
|
|
||||||
pub fn client(&self) -> &Client {
|
|
||||||
&self.http_client
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取应用配置目录
|
|
||||||
pub fn get_app_config_dir(app_type: &str) -> Result<PathBuf> {
|
|
||||||
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
|
||||||
|
|
||||||
let dir = match app_type.to_lowercase().as_str() {
|
|
||||||
"claude" => {
|
|
||||||
// 检查是否有自定义 Claude 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
|
||||||
custom
|
|
||||||
} else {
|
|
||||||
home.join(".claude")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"codex" => {
|
|
||||||
// 检查是否有自定义 Codex 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
|
||||||
custom
|
|
||||||
} else {
|
|
||||||
home.join(".codex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"gemini" => {
|
|
||||||
// 检查是否有自定义 Gemini 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
|
||||||
custom
|
|
||||||
} else {
|
|
||||||
home.join(".gemini")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("不支持的应用类型: {app_type}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 components.json 获取市场组合
|
|
||||||
pub async fn fetch_marketplace_bundles(
|
|
||||||
&self,
|
|
||||||
conn: &rusqlite::Connection,
|
|
||||||
) -> Result<Vec<MarketplaceBundle>> {
|
|
||||||
// 获取启用的仓库
|
|
||||||
let repos = self.list_enabled_repos(conn)?;
|
|
||||||
if repos.is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut bundles = Vec::new();
|
|
||||||
|
|
||||||
for repo in repos {
|
|
||||||
// 尝试多个可能的路径
|
|
||||||
let urls = [
|
|
||||||
format!(
|
|
||||||
"https://raw.githubusercontent.com/{}/{}/{}/components.json",
|
|
||||||
repo.owner, repo.name, repo.branch
|
|
||||||
),
|
|
||||||
format!(
|
|
||||||
"https://raw.githubusercontent.com/{}/{}/{}/docs/components.json",
|
|
||||||
repo.owner, repo.name, repo.branch
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for url in urls {
|
|
||||||
match self.http_client.get(&url).send().await {
|
|
||||||
Ok(resp) if resp.status().is_success() => {
|
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
|
||||||
// 解析 marketplace.plugins(完整插件包)
|
|
||||||
if let Some(marketplace) = json.get("marketplace") {
|
|
||||||
if let Some(plugins) = marketplace.get("plugins") {
|
|
||||||
if let Some(arr) = plugins.as_array() {
|
|
||||||
for plugin in arr {
|
|
||||||
let name = plugin
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let description = plugin
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// 提取各类型组件路径
|
|
||||||
let mut components = Vec::new();
|
|
||||||
let component_types = [
|
|
||||||
"agents", "commands", "mcps", "settings", "hooks",
|
|
||||||
"skills",
|
|
||||||
];
|
|
||||||
|
|
||||||
for comp_type in component_types {
|
|
||||||
if let Some(paths) =
|
|
||||||
plugin.get(comp_type).and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
// 单数形式的类型名
|
|
||||||
let singular_type = match comp_type {
|
|
||||||
"agents" => "agent",
|
|
||||||
"commands" => "command",
|
|
||||||
"mcps" => "mcp",
|
|
||||||
"settings" => "setting",
|
|
||||||
"hooks" => "hook",
|
|
||||||
"skills" => "skill",
|
|
||||||
_ => comp_type,
|
|
||||||
};
|
|
||||||
|
|
||||||
for path_val in paths {
|
|
||||||
if let Some(path) = path_val.as_str() {
|
|
||||||
// 从路径提取组件名(文件名不含扩展名)
|
|
||||||
let comp_name =
|
|
||||||
std::path::Path::new(path)
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
components.push(
|
|
||||||
MarketplaceBundleItem {
|
|
||||||
name: comp_name,
|
|
||||||
path: path.to_string(),
|
|
||||||
component_type: singular_type
|
|
||||||
.to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !components.is_empty() {
|
|
||||||
bundles.push(MarketplaceBundle {
|
|
||||||
id: format!("{}-plugin-{}", repo.name, name),
|
|
||||||
name: name.to_string(),
|
|
||||||
description: description.to_string(),
|
|
||||||
category: "plugin".to_string(),
|
|
||||||
components,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break; // 成功获取后跳出 URL 循环
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(bundles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TemplateService {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new().expect("创建 TemplateService 失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
|
|
||||||
use super::{TemplateRepo, TemplateService};
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl TemplateService {
|
|
||||||
/// 列出所有模板仓库
|
|
||||||
pub fn list_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.context("准备查询模板仓库语句失败")?;
|
|
||||||
|
|
||||||
let repos = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get::<_, i64>(4)? != 0,
|
|
||||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
|
||||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.context("查询模板仓库失败")?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.context("收集模板仓库结果失败")?;
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加模板仓库
|
|
||||||
pub fn add_repo(&self, conn: &Connection, repo: TemplateRepo) -> Result<i64> {
|
|
||||||
// 检查是否已存在
|
|
||||||
let existing: Option<i64> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM template_repos WHERE owner = ?1 AND name = ?2",
|
|
||||||
params![&repo.owner, &repo.name],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
if let Some(id) = existing {
|
|
||||||
// 更新已存在的仓库
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos
|
|
||||||
SET branch = ?1, enabled = ?2, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?3",
|
|
||||||
params![&repo.branch, repo.enabled as i64, id],
|
|
||||||
)
|
|
||||||
.context("更新模板仓库失败")?;
|
|
||||||
Ok(id)
|
|
||||||
} else {
|
|
||||||
// 插入新仓库
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_repos (owner, name, branch, enabled)
|
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![&repo.owner, &repo.name, &repo.branch, repo.enabled as i64],
|
|
||||||
)
|
|
||||||
.context("插入模板仓库失败")?;
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模板仓库
|
|
||||||
pub fn remove_repo(&self, conn: &Connection, id: i64) -> Result<()> {
|
|
||||||
let rows = conn
|
|
||||||
.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
|
||||||
.context("删除模板仓库失败")?;
|
|
||||||
|
|
||||||
if rows == 0 {
|
|
||||||
anyhow::bail!("模板仓库不存在: id={id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换仓库启用状态
|
|
||||||
pub fn toggle_repo_enabled(&self, conn: &Connection, id: i64) -> Result<bool> {
|
|
||||||
// 获取当前状态
|
|
||||||
let enabled: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT enabled FROM template_repos WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.context("查询仓库状态失败")?;
|
|
||||||
|
|
||||||
let new_enabled = enabled == 0;
|
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos
|
|
||||||
SET enabled = ?1, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?2",
|
|
||||||
params![new_enabled as i64, id],
|
|
||||||
)
|
|
||||||
.context("更新仓库状态失败")?;
|
|
||||||
|
|
||||||
Ok(new_enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个仓库
|
|
||||||
pub fn get_repo(&self, conn: &Connection, id: i64) -> Result<TemplateRepo> {
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get::<_, i64>(4)? != 0,
|
|
||||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
|
||||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.context(format!("查询模板仓库失败: id={id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取启用的仓库列表
|
|
||||||
pub fn list_enabled_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
WHERE enabled = 1
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.context("准备查询启用仓库语句失败")?;
|
|
||||||
|
|
||||||
let repos = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get::<_, i64>(4)? != 0,
|
|
||||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
|
||||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.context("查询启用仓库失败")?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.context("收集启用仓库结果失败")?;
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use rusqlite::Connection;
|
|
||||||
|
|
||||||
fn setup_db() -> Connection {
|
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_and_list_repos() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let service = TemplateService::new().unwrap();
|
|
||||||
|
|
||||||
// 添加仓库
|
|
||||||
let repo = TemplateRepo::new(
|
|
||||||
"yovinchen".to_string(),
|
|
||||||
"claude-code-templates".to_string(),
|
|
||||||
"main".to_string(),
|
|
||||||
);
|
|
||||||
let id = service.add_repo(&conn, repo).unwrap();
|
|
||||||
assert!(id > 0);
|
|
||||||
|
|
||||||
// 列出仓库
|
|
||||||
let repos = service.list_repos(&conn).unwrap();
|
|
||||||
assert_eq!(repos.len(), 1);
|
|
||||||
assert_eq!(repos[0].owner, "yovinchen");
|
|
||||||
assert_eq!(repos[0].name, "claude-code-templates");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_toggle_repo_enabled() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let service = TemplateService::new().unwrap();
|
|
||||||
|
|
||||||
// 添加仓库
|
|
||||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
|
||||||
let id = service.add_repo(&conn, repo).unwrap();
|
|
||||||
|
|
||||||
// 切换状态
|
|
||||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
|
||||||
assert!(!enabled);
|
|
||||||
|
|
||||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
|
||||||
assert!(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_remove_repo() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let service = TemplateService::new().unwrap();
|
|
||||||
|
|
||||||
// 添加仓库
|
|
||||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
|
||||||
let id = service.add_repo(&conn, repo).unwrap();
|
|
||||||
|
|
||||||
// 删除仓库
|
|
||||||
service.remove_repo(&conn, id).unwrap();
|
|
||||||
|
|
||||||
// 验证已删除
|
|
||||||
let repos = service.list_repos(&conn).unwrap();
|
|
||||||
assert_eq!(repos.len(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -856,7 +856,9 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"应该不匹配的URL被允许: base_url={base_url}, request_url={request_url}"
|
"应该不匹配的URL被允许: base_url={}, request_url={}",
|
||||||
|
base_url,
|
||||||
|
request_url
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-15
@@ -13,7 +13,6 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Server,
|
Server,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Package,
|
|
||||||
Search,
|
Search,
|
||||||
Download,
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -49,7 +48,6 @@ import { SkillsPage } from "@/components/skills/SkillsPage";
|
|||||||
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||||
import { TemplatesPage } from "@/components/templates/TemplatesPage";
|
|
||||||
import { UniversalProviderPanel } from "@/components/universal";
|
import { UniversalProviderPanel } from "@/components/universal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -60,7 +58,6 @@ type View =
|
|||||||
| "skills"
|
| "skills"
|
||||||
| "skillsDiscovery"
|
| "skillsDiscovery"
|
||||||
| "mcp"
|
| "mcp"
|
||||||
| "templates"
|
|
||||||
| "agents"
|
| "agents"
|
||||||
| "universal";
|
| "universal";
|
||||||
|
|
||||||
@@ -475,8 +472,6 @@ function App() {
|
|||||||
<UniversalProviderPanel />
|
<UniversalProviderPanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "templates":
|
|
||||||
return <TemplatesPage activeApp={activeApp} />;
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
@@ -617,7 +612,6 @@ function App() {
|
|||||||
{currentView === "skillsDiscovery" && t("skills.title")}
|
{currentView === "skillsDiscovery" && t("skills.title")}
|
||||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||||
{currentView === "agents" && t("agents.title")}
|
{currentView === "agents" && t("agents.title")}
|
||||||
{currentView === "templates" && t("templates.title")}
|
|
||||||
{currentView === "universal" &&
|
{currentView === "universal" &&
|
||||||
t("universalProvider.title", {
|
t("universalProvider.title", {
|
||||||
defaultValue: "统一供应商",
|
defaultValue: "统一供应商",
|
||||||
@@ -796,15 +790,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Server className="w-4 h-4" />
|
<Server className="w-4 h-4" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState, useEffect, useRef } from "react";
|
import { useMemo, useState, useEffect } from "react";
|
||||||
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
@@ -149,10 +149,6 @@ export function ProviderCard({
|
|||||||
// 多套餐默认展开
|
// 多套餐默认展开
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
// 操作按钮容器 ref,用于动态计算宽度
|
|
||||||
const actionsRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [actionsWidth, setActionsWidth] = useState(0);
|
|
||||||
|
|
||||||
// 当检测到多套餐时自动展开
|
// 当检测到多套餐时自动展开
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMultiplePlans) {
|
if (hasMultiplePlans) {
|
||||||
@@ -160,20 +156,6 @@ export function ProviderCard({
|
|||||||
}
|
}
|
||||||
}, [hasMultiplePlans]);
|
}, [hasMultiplePlans]);
|
||||||
|
|
||||||
// 动态获取操作按钮宽度
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionsRef.current) {
|
|
||||||
const updateWidth = () => {
|
|
||||||
const width = actionsRef.current?.offsetWidth || 0;
|
|
||||||
setActionsWidth(width);
|
|
||||||
};
|
|
||||||
updateWidth();
|
|
||||||
// 监听窗口大小变化
|
|
||||||
window.addEventListener("resize", updateWidth);
|
|
||||||
return () => window.removeEventListener("resize", updateWidth);
|
|
||||||
}
|
|
||||||
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
|
|
||||||
|
|
||||||
const handleOpenWebsite = () => {
|
const handleOpenWebsite = () => {
|
||||||
if (!isClickableUrl) {
|
if (!isClickableUrl) {
|
||||||
return;
|
return;
|
||||||
@@ -299,17 +281,10 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="relative flex items-center ml-auto min-w-0">
|
||||||
className="relative flex items-center ml-auto min-w-0 gap-3"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--actions-width": `${actionsWidth || 320}px`,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
|
||||||
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
|
<div className="flex items-center gap-1">
|
||||||
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
||||||
{hasMultiplePlans ? (
|
{hasMultiplePlans ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
@@ -354,11 +329,8 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
|
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
|
||||||
<div
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
||||||
ref={actionsRef}
|
|
||||||
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
|
||||||
>
|
|
||||||
<ProviderActions
|
<ProviderActions
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
|
|
||||||
|
|
||||||
export function RectifierConfigPanel() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [config, setConfig] = useState<RectifierConfig>({
|
|
||||||
enabled: true,
|
|
||||||
requestThinkingSignature: true,
|
|
||||||
});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
settingsApi
|
|
||||||
.getRectifierConfig()
|
|
||||||
.then(setConfig)
|
|
||||||
.catch((e) => console.error("Failed to load rectifier config:", e))
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = async (updates: Partial<RectifierConfig>) => {
|
|
||||||
const newConfig = { ...config, ...updates };
|
|
||||||
setConfig(newConfig);
|
|
||||||
try {
|
|
||||||
await settingsApi.setRectifierConfig(newConfig);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to save rectifier config:", e);
|
|
||||||
toast.error(String(e));
|
|
||||||
setConfig(config);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>{t("settings.advanced.rectifier.enabled")}</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("settings.advanced.rectifier.enabledDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={config.enabled}
|
|
||||||
onCheckedChange={(checked) => handleChange({ enabled: checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
|
||||||
{t("settings.advanced.rectifier.requestGroup")}
|
|
||||||
</h4>
|
|
||||||
<div className="flex items-center justify-between pl-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>{t("settings.advanced.rectifier.thinkingSignature")}</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("settings.advanced.rectifier.thinkingSignatureDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={config.requestThinkingSignature}
|
|
||||||
disabled={!config.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleChange({ requestThinkingSignature: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Server,
|
Server,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Zap,
|
|
||||||
Globe,
|
Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
@@ -43,7 +42,6 @@ import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
|||||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
||||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||||
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
import { useImportExport } from "@/hooks/useImportExport";
|
import { useImportExport } from "@/hooks/useImportExport";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -552,28 +550,6 @@ export function SettingsPage({
|
|||||||
/>
|
/>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</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>
|
</Accordion>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Download, Loader2, Trash2 } from "lucide-react";
|
|
||||||
import type { MarketplaceBundle } from "@/types/template";
|
|
||||||
import type { AppType } from "@/lib/api/config";
|
|
||||||
|
|
||||||
// 组件类型图标映射
|
|
||||||
const componentTypeIcons: Record<string, string> = {
|
|
||||||
agent: "🤖",
|
|
||||||
command: "⚡",
|
|
||||||
mcp: "🔌",
|
|
||||||
setting: "⚙️",
|
|
||||||
hook: "🪝",
|
|
||||||
skill: "💡",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BundleInstallStatus {
|
|
||||||
installed: boolean;
|
|
||||||
installedIds: number[];
|
|
||||||
totalCount: number;
|
|
||||||
installedCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BundleDetailProps {
|
|
||||||
bundle: MarketplaceBundle;
|
|
||||||
status?: BundleInstallStatus;
|
|
||||||
selectedApp: AppType;
|
|
||||||
onClose: () => void;
|
|
||||||
onInstall: () => void;
|
|
||||||
onUninstall: () => void;
|
|
||||||
installing: boolean;
|
|
||||||
uninstalling: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BundleDetail({
|
|
||||||
bundle,
|
|
||||||
status,
|
|
||||||
onClose,
|
|
||||||
onInstall,
|
|
||||||
onUninstall,
|
|
||||||
installing,
|
|
||||||
uninstalling,
|
|
||||||
}: BundleDetailProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// 按类型分组组件
|
|
||||||
const componentsByType = bundle.components.reduce(
|
|
||||||
(acc, comp) => {
|
|
||||||
const type = comp.componentType;
|
|
||||||
if (!acc[type]) acc[type] = [];
|
|
||||||
acc[type].push(comp);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, typeof bundle.components>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-3xl">📦</span>
|
|
||||||
{status?.installed && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
|
||||||
>
|
|
||||||
{t("templates.installed", { defaultValue: "已安装" })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-2xl">{bundle.name}</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm mt-2">
|
|
||||||
{bundle.description ||
|
|
||||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* 组件列表 */}
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-6 py-4 px-1">
|
|
||||||
{Object.entries(componentsByType).map(([type, components]) => (
|
|
||||||
<div key={type}>
|
|
||||||
<div className="flex items-center justify-center gap-2 mb-3">
|
|
||||||
<span className="text-xl">
|
|
||||||
{componentTypeIcons[type] || "📦"}
|
|
||||||
</span>
|
|
||||||
<h3 className="font-medium text-foreground">
|
|
||||||
{t(`templates.type.${type}`, { defaultValue: type })}
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{components.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 px-2">
|
|
||||||
{components.map((comp, idx) => (
|
|
||||||
<div
|
|
||||||
key={`${comp.name}-${idx}`}
|
|
||||||
className="flex items-center gap-3 p-3 rounded-lg bg-muted/30"
|
|
||||||
>
|
|
||||||
<span className="text-lg">
|
|
||||||
{componentTypeIcons[type] || "📦"}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium text-sm truncate">
|
|
||||||
{comp.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{comp.path}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
{t("common.close", { defaultValue: "关闭" })}
|
|
||||||
</Button>
|
|
||||||
{status && status.installedCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={onUninstall}
|
|
||||||
disabled={uninstalling}
|
|
||||||
>
|
|
||||||
{uninstalling ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!status?.installed && (
|
|
||||||
<Button onClick={onInstall} disabled={installing}>
|
|
||||||
{installing ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{t("templates.bundle.install", { defaultValue: "安装组合" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Download, Loader2, Package, Trash2, FileText } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
useMarketplaceBundles,
|
|
||||||
useBatchInstallComponents,
|
|
||||||
} from "@/lib/query/template";
|
|
||||||
import { templateApi } from "@/lib/api/template";
|
|
||||||
import { BundleDetail } from "./BundleDetail";
|
|
||||||
import type { MarketplaceBundle, ComponentType } from "@/types/template";
|
|
||||||
import type { AppType } from "@/lib/api/config";
|
|
||||||
|
|
||||||
// 组件类型图标映射
|
|
||||||
const componentTypeIcons: Record<string, string> = {
|
|
||||||
agent: "🤖",
|
|
||||||
command: "⚡",
|
|
||||||
mcp: "🔌",
|
|
||||||
setting: "⚙️",
|
|
||||||
hook: "🪝",
|
|
||||||
skill: "💡",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BundleInstallStatus {
|
|
||||||
installed: boolean;
|
|
||||||
installedIds: number[];
|
|
||||||
totalCount: number;
|
|
||||||
installedCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BundleListProps {
|
|
||||||
selectedApp: AppType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计组件类型数量
|
|
||||||
function getComponentTypeCounts(components: MarketplaceBundle["components"]) {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const comp of components) {
|
|
||||||
const type = comp.componentType;
|
|
||||||
counts[type] = (counts[type] || 0) + 1;
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BundleList({ selectedApp }: BundleListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [installingBundle, setInstallingBundle] = useState<string | null>(null);
|
|
||||||
const [uninstallingBundle, setUninstallingBundle] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [bundleStatuses, setBundleStatuses] = useState<
|
|
||||||
Record<string, BundleInstallStatus>
|
|
||||||
>({});
|
|
||||||
const [detailBundle, setDetailBundle] = useState<MarketplaceBundle | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: bundles = [], isLoading } = useMarketplaceBundles();
|
|
||||||
const batchInstallMutation = useBatchInstallComponents();
|
|
||||||
|
|
||||||
// 检查组合安装状态
|
|
||||||
const checkBundleStatus = useCallback(
|
|
||||||
async (bundle: MarketplaceBundle): Promise<BundleInstallStatus> => {
|
|
||||||
const componentsByType = bundle.components.reduce(
|
|
||||||
(acc, comp) => {
|
|
||||||
const type = comp.componentType;
|
|
||||||
if (!acc[type]) acc[type] = [];
|
|
||||||
acc[type].push(comp.name.toLowerCase());
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string[]>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const installedIds: number[] = [];
|
|
||||||
let totalMatched = 0;
|
|
||||||
|
|
||||||
for (const [componentType, names] of Object.entries(componentsByType)) {
|
|
||||||
const componentsData = await templateApi.listTemplateComponents({
|
|
||||||
componentType: componentType as ComponentType,
|
|
||||||
pageSize: 1000,
|
|
||||||
appType: selectedApp,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const comp of componentsData.items) {
|
|
||||||
if (names.includes(comp.name.toLowerCase())) {
|
|
||||||
totalMatched++;
|
|
||||||
if (comp.installed && comp.id !== null) {
|
|
||||||
installedIds.push(comp.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
installed:
|
|
||||||
installedIds.length > 0 && installedIds.length === totalMatched,
|
|
||||||
installedIds,
|
|
||||||
totalCount: totalMatched,
|
|
||||||
installedCount: installedIds.length,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[selectedApp],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 加载所有组合的安装状态
|
|
||||||
useEffect(() => {
|
|
||||||
const loadStatuses = async () => {
|
|
||||||
const statuses: Record<string, BundleInstallStatus> = {};
|
|
||||||
for (const bundle of bundles) {
|
|
||||||
statuses[bundle.id] = await checkBundleStatus(bundle);
|
|
||||||
}
|
|
||||||
setBundleStatuses(statuses);
|
|
||||||
};
|
|
||||||
if (bundles.length > 0) {
|
|
||||||
loadStatuses();
|
|
||||||
}
|
|
||||||
}, [bundles, checkBundleStatus]);
|
|
||||||
|
|
||||||
const handleInstallBundle = async (bundle: MarketplaceBundle) => {
|
|
||||||
setInstallingBundle(bundle.id);
|
|
||||||
try {
|
|
||||||
// 按组件类型分组
|
|
||||||
const componentsByType = bundle.components.reduce(
|
|
||||||
(acc, comp) => {
|
|
||||||
const type = comp.componentType;
|
|
||||||
if (!acc[type]) acc[type] = [];
|
|
||||||
acc[type].push(comp.name.toLowerCase());
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string[]>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 收集所有匹配的组件 ID
|
|
||||||
const matchedIds: number[] = [];
|
|
||||||
|
|
||||||
for (const [componentType, names] of Object.entries(componentsByType)) {
|
|
||||||
const componentsData = await templateApi.listTemplateComponents({
|
|
||||||
componentType: componentType as ComponentType,
|
|
||||||
pageSize: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ids = componentsData.items
|
|
||||||
.filter((c) => names.includes(c.name.toLowerCase()))
|
|
||||||
.map((c) => c.id)
|
|
||||||
.filter((id): id is number => id !== null);
|
|
||||||
|
|
||||||
matchedIds.push(...ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedIds.length === 0) {
|
|
||||||
toast.warning(
|
|
||||||
t("templates.bundle.noMatch", {
|
|
||||||
defaultValue: "未找到匹配的组件",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await batchInstallMutation.mutateAsync({
|
|
||||||
ids: matchedIds,
|
|
||||||
appType: selectedApp,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
t("templates.bundle.installSuccess", {
|
|
||||||
count: result.success.length,
|
|
||||||
defaultValue: `已安装 ${result.success.length} 个组件`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.failed.length > 0) {
|
|
||||||
toast.warning(
|
|
||||||
t("templates.bundle.partialFail", {
|
|
||||||
count: result.failed.length,
|
|
||||||
defaultValue: `${result.failed.length} 个组件安装失败`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新安装状态
|
|
||||||
const newStatus = await checkBundleStatus(bundle);
|
|
||||||
setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus }));
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(
|
|
||||||
t("templates.bundle.installFailed", { defaultValue: "安装组合失败" }),
|
|
||||||
{ description: errorMessage, duration: 8000 },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setInstallingBundle(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstallBundle = async (bundle: MarketplaceBundle) => {
|
|
||||||
const status = bundleStatuses[bundle.id];
|
|
||||||
if (!status || status.installedIds.length === 0) return;
|
|
||||||
|
|
||||||
setUninstallingBundle(bundle.id);
|
|
||||||
try {
|
|
||||||
let successCount = 0;
|
|
||||||
let failCount = 0;
|
|
||||||
|
|
||||||
for (const id of status.installedIds) {
|
|
||||||
try {
|
|
||||||
await templateApi.uninstallTemplateComponent(id, selectedApp);
|
|
||||||
successCount++;
|
|
||||||
} catch {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount > 0) {
|
|
||||||
toast.success(
|
|
||||||
t("templates.bundle.uninstallSuccess", {
|
|
||||||
count: successCount,
|
|
||||||
defaultValue: `已卸载 ${successCount} 个组件`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failCount > 0) {
|
|
||||||
toast.warning(
|
|
||||||
t("templates.bundle.uninstallPartialFail", {
|
|
||||||
count: failCount,
|
|
||||||
defaultValue: `${failCount} 个组件卸载失败`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新安装状态
|
|
||||||
const newStatus = await checkBundleStatus(bundle);
|
|
||||||
setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus }));
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(
|
|
||||||
t("templates.bundle.uninstallFailed", { defaultValue: "卸载组合失败" }),
|
|
||||||
{ description: errorMessage, duration: 8000 },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setUninstallingBundle(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bundles.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
|
||||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-lg font-medium text-foreground">
|
|
||||||
{t("templates.bundle.empty", { defaultValue: "暂无组合" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
{t("templates.bundle.emptyDescription", {
|
|
||||||
defaultValue: "请添加包含 components.json 的模板仓库",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{bundles.map((bundle) => {
|
|
||||||
const typeCounts = getComponentTypeCounts(bundle.components);
|
|
||||||
const status = bundleStatuses[bundle.id];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={bundle.id}
|
|
||||||
className="glass-card rounded-xl p-4 flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer"
|
|
||||||
onClick={() => setDetailBundle(bundle)}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
|
||||||
|
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex items-start justify-between gap-2 mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-2xl">📦</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-foreground truncate">
|
|
||||||
{bundle.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{status?.installed && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
|
||||||
>
|
|
||||||
{t("templates.installed", { defaultValue: "已安装" })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
<p className="text-sm text-muted-foreground/90 line-clamp-2 leading-relaxed mb-3 flex-1">
|
|
||||||
{bundle.description ||
|
|
||||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 组件类型统计 */}
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
||||||
{Object.entries(typeCounts).map(([type, count]) => (
|
|
||||||
<Badge key={type} variant="secondary" className="text-xs">
|
|
||||||
<span className="mr-1">
|
|
||||||
{componentTypeIcons[type] || "📦"}
|
|
||||||
</span>
|
|
||||||
{type} {count}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部操作栏 */}
|
|
||||||
<div className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDetailBundle(bundle);
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{t("templates.viewDetail", { defaultValue: "查看详情" })}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{status && status.installedCount > 0 && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleUninstallBundle(bundle);
|
|
||||||
}}
|
|
||||||
disabled={uninstallingBundle === bundle.id}
|
|
||||||
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
|
|
||||||
>
|
|
||||||
{uninstallingBundle === bundle.id ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
)}
|
|
||||||
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!status?.installed && (
|
|
||||||
<Button
|
|
||||||
variant="mcp"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleInstallBundle(bundle);
|
|
||||||
}}
|
|
||||||
disabled={installingBundle === bundle.id}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{installingBundle === bundle.id ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
)}
|
|
||||||
{t("templates.bundle.install", { defaultValue: "安装" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 详情弹窗 */}
|
|
||||||
{detailBundle && (
|
|
||||||
<BundleDetail
|
|
||||||
bundle={detailBundle}
|
|
||||||
status={bundleStatuses[detailBundle.id]}
|
|
||||||
selectedApp={selectedApp}
|
|
||||||
onClose={() => setDetailBundle(null)}
|
|
||||||
onInstall={() => handleInstallBundle(detailBundle)}
|
|
||||||
onUninstall={() => handleUninstallBundle(detailBundle)}
|
|
||||||
installing={installingBundle === detailBundle.id}
|
|
||||||
uninstalling={uninstallingBundle === detailBundle.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
interface CategoryFilterProps {
|
|
||||||
categories: string[];
|
|
||||||
selectedCategory?: string;
|
|
||||||
onSelectCategory: (category: string | undefined) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryFilter({
|
|
||||||
categories,
|
|
||||||
selectedCategory,
|
|
||||||
onSelectCategory,
|
|
||||||
}: CategoryFilterProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass-card rounded-xl p-4 sticky top-0">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
|
||||||
{t("templates.category.title", { defaultValue: "分类" })}
|
|
||||||
</h3>
|
|
||||||
<div className="h-[calc(100vh-16rem)] overflow-y-auto">
|
|
||||||
<div className="space-y-1 pr-2">
|
|
||||||
{/* 全部选项 */}
|
|
||||||
<Button
|
|
||||||
variant={selectedCategory === undefined ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSelectCategory(undefined)}
|
|
||||||
className="w-full justify-start text-sm h-9"
|
|
||||||
>
|
|
||||||
{t("templates.category.all", { defaultValue: "全部" })}
|
|
||||||
{selectedCategory === undefined && (
|
|
||||||
<Badge variant="secondary" className="ml-auto text-xs">
|
|
||||||
✓
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 分类列表 */}
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="h-px bg-border my-2" />
|
|
||||||
{categories.map((category) => (
|
|
||||||
<Button
|
|
||||||
key={category}
|
|
||||||
variant={
|
|
||||||
selectedCategory === category ? "secondary" : "ghost"
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSelectCategory(category)}
|
|
||||||
className="w-full justify-start text-sm h-9"
|
|
||||||
>
|
|
||||||
<span className="truncate">{category}</span>
|
|
||||||
{selectedCategory === category && (
|
|
||||||
<Badge variant="secondary" className="ml-auto text-xs">
|
|
||||||
✓
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 无分类提示 */}
|
|
||||||
{categories.length === 0 && selectedCategory === undefined && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">
|
|
||||||
{t("templates.category.empty", { defaultValue: "暂无分类" })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Download, Trash2, Loader2, FileText } from "lucide-react";
|
|
||||||
import type { TemplateComponent } from "@/types/template";
|
|
||||||
|
|
||||||
interface ComponentCardProps {
|
|
||||||
component: TemplateComponent;
|
|
||||||
onInstall: () => Promise<void>;
|
|
||||||
onUninstall: () => Promise<void>;
|
|
||||||
onViewDetail: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件类型图标映射
|
|
||||||
const componentTypeIcons: Record<string, string> = {
|
|
||||||
agent: "🤖",
|
|
||||||
command: "⚡",
|
|
||||||
mcp: "🔌",
|
|
||||||
setting: "⚙️",
|
|
||||||
hook: "🪝",
|
|
||||||
skill: "💡",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ComponentCard({
|
|
||||||
component,
|
|
||||||
onInstall,
|
|
||||||
onUninstall,
|
|
||||||
onViewDetail,
|
|
||||||
}: ComponentCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onInstall();
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstall = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onUninstall();
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeIcon = componentTypeIcons[component.componentType] || "📦";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
|
||||||
|
|
||||||
<div onClick={onViewDetail}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-2xl">{typeIcon}</span>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold truncate">
|
|
||||||
{component.name}
|
|
||||||
</CardTitle>
|
|
||||||
{component.category && (
|
|
||||||
<CardDescription className="text-xs mt-1">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4 border-border-default"
|
|
||||||
>
|
|
||||||
{component.category}
|
|
||||||
</Badge>
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{component.installed && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
|
||||||
>
|
|
||||||
{t("templates.installed", { defaultValue: "已安装" })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex-1 pt-0">
|
|
||||||
<p className="text-sm text-muted-foreground/90 line-clamp-3 leading-relaxed">
|
|
||||||
{component.description ||
|
|
||||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardFooter className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onViewDetail();
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{t("templates.viewDetail", { defaultValue: "查看详情" })}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{component.installed ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleUninstall();
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
)}
|
|
||||||
{loading
|
|
||||||
? t("templates.uninstalling", { defaultValue: "卸载中..." })
|
|
||||||
: t("templates.uninstall", { defaultValue: "卸载" })}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="mcp"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleInstall();
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
)}
|
|
||||||
{loading
|
|
||||||
? t("templates.installing", { defaultValue: "安装中..." })
|
|
||||||
: t("templates.install", { defaultValue: "安装" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
ExternalLink,
|
|
||||||
FileCode,
|
|
||||||
FolderGit2,
|
|
||||||
Tag,
|
|
||||||
Clock,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { settingsApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
useTemplateComponent,
|
|
||||||
useComponentPreview,
|
|
||||||
} from "@/lib/query/template";
|
|
||||||
import type { AppType } from "@/lib/api/config";
|
|
||||||
|
|
||||||
interface ComponentDetailProps {
|
|
||||||
componentId: number;
|
|
||||||
selectedApp: AppType;
|
|
||||||
onClose: () => void;
|
|
||||||
onInstall: (id: number, name: string) => Promise<void>;
|
|
||||||
onUninstall: (id: number, name: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件类型图标映射
|
|
||||||
const componentTypeIcons: Record<string, string> = {
|
|
||||||
agent: "🤖",
|
|
||||||
command: "⚡",
|
|
||||||
mcp: "🔌",
|
|
||||||
setting: "⚙️",
|
|
||||||
hook: "🪝",
|
|
||||||
skill: "💡",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ComponentDetail({
|
|
||||||
componentId,
|
|
||||||
onClose,
|
|
||||||
onInstall,
|
|
||||||
onUninstall,
|
|
||||||
}: ComponentDetailProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const { data: component, isLoading: componentLoading } =
|
|
||||||
useTemplateComponent(componentId);
|
|
||||||
const { data: preview, isLoading: previewLoading } =
|
|
||||||
useComponentPreview(componentId);
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
|
||||||
if (!component || component.id === null) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onInstall(component.id, component.name);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstall = async () => {
|
|
||||||
if (!component || component.id === null) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onUninstall(component.id, component.name);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenGithub = async () => {
|
|
||||||
if (component?.readmeUrl) {
|
|
||||||
try {
|
|
||||||
await settingsApi.openExternal(component.readmeUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to open URL:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (componentLoading || !component) {
|
|
||||||
return (
|
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeIcon = componentTypeIcons[component.componentType] || "📦";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-3xl">{typeIcon}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{t(`templates.type.${component.componentType}`, {
|
|
||||||
defaultValue: component.componentType,
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
{component.category && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{component.category}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-2xl">{component.name}</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm mt-2">
|
|
||||||
{component.description ||
|
|
||||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
{component.installed && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="shrink-0 bg-green-600/90 text-white border-0"
|
|
||||||
>
|
|
||||||
{t("templates.installed", { defaultValue: "已安装" })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<Tabs defaultValue="info" className="h-full flex flex-col">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="info">
|
|
||||||
{t("templates.detail.info", { defaultValue: "信息" })}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="preview">
|
|
||||||
<FileCode className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{t("templates.detail.preview", { defaultValue: "预览" })}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="info" className="flex-1 overflow-y-auto mt-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{/* 类型 */}
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
||||||
<Tag className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.detail.type", { defaultValue: "类型" })}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{t(`templates.type.${component.componentType}`, {
|
|
||||||
defaultValue: component.componentType,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分类 */}
|
|
||||||
{component.category && (
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
||||||
<Tag className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.detail.category", {
|
|
||||||
defaultValue: "分类",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{component.category}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 仓库 */}
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
||||||
<FolderGit2 className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.detail.repository", {
|
|
||||||
defaultValue: "仓库",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{component.repoOwner}/{component.repoName}
|
|
||||||
{component.repoBranch &&
|
|
||||||
component.repoBranch !== "main" &&
|
|
||||||
` (${component.repoBranch})`}
|
|
||||||
</p>
|
|
||||||
{component.readmeUrl && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleOpenGithub}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 更新时间 */}
|
|
||||||
{component.updatedAt && (
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
||||||
<Clock className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.detail.updatedAt", {
|
|
||||||
defaultValue: "更新时间",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{new Date(component.updatedAt).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 路径 */}
|
|
||||||
<div className="mt-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">
|
|
||||||
{t("templates.detail.path", { defaultValue: "路径" })}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-mono text-muted-foreground break-all">
|
|
||||||
{component.path}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value="preview"
|
|
||||||
className="flex-1 overflow-y-auto mt-4"
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : preview ? (
|
|
||||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-auto font-mono whitespace-pre-wrap break-words">
|
|
||||||
{preview}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
|
||||||
{t("templates.detail.noPreview", {
|
|
||||||
defaultValue: "无法预览内容",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
{t("common.close", { defaultValue: "关闭" })}
|
|
||||||
</Button>
|
|
||||||
{component.installed ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleUninstall}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{loading
|
|
||||||
? t("templates.uninstalling", { defaultValue: "卸载中..." })
|
|
||||||
: t("templates.uninstall", { defaultValue: "卸载" })}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={handleInstall} disabled={loading}>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{loading
|
|
||||||
? t("templates.installing", { defaultValue: "安装中..." })
|
|
||||||
: t("templates.install", { defaultValue: "安装" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Trash2, ExternalLink, Plus, Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { settingsApi } from "@/lib/api";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import {
|
|
||||||
useTemplateRepos,
|
|
||||||
useAddTemplateRepo,
|
|
||||||
useRemoveTemplateRepo,
|
|
||||||
useToggleTemplateRepo,
|
|
||||||
} from "@/lib/query/template";
|
|
||||||
|
|
||||||
interface RepoManagerProps {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepoManager({ onClose }: RepoManagerProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [repoUrl, setRepoUrl] = useState("");
|
|
||||||
const [branch, setBranch] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const { data: repos = [], isLoading } = useTemplateRepos();
|
|
||||||
const addRepoMutation = useAddTemplateRepo();
|
|
||||||
const removeRepoMutation = useRemoveTemplateRepo();
|
|
||||||
const toggleRepoMutation = useToggleTemplateRepo();
|
|
||||||
|
|
||||||
const parseRepoUrl = (
|
|
||||||
url: string,
|
|
||||||
): { owner: string; name: string } | null => {
|
|
||||||
let cleaned = url.trim();
|
|
||||||
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
|
|
||||||
cleaned = cleaned.replace(/\.git$/, "");
|
|
||||||
|
|
||||||
const parts = cleaned.split("/");
|
|
||||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
||||||
return { owner: parts[0], name: parts[1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
const parsed = parseRepoUrl(repoUrl);
|
|
||||||
if (!parsed) {
|
|
||||||
setError(
|
|
||||||
t("templates.repo.invalidUrl", {
|
|
||||||
defaultValue: "仓库地址格式不正确",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addRepoMutation.mutateAsync({
|
|
||||||
owner: parsed.owner,
|
|
||||||
name: parsed.name,
|
|
||||||
branch: branch || "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
t("templates.repo.addSuccess", {
|
|
||||||
owner: parsed.owner,
|
|
||||||
name: parsed.name,
|
|
||||||
defaultValue: `已添加仓库 ${parsed.owner}/${parsed.name}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
setRepoUrl("");
|
|
||||||
setBranch("");
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
setError(
|
|
||||||
t("templates.repo.addFailed", {
|
|
||||||
defaultValue: "添加仓库失败",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
toast.error(
|
|
||||||
t("templates.repo.addFailed", { defaultValue: "添加仓库失败" }),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = async (id: number, owner: string, name: string) => {
|
|
||||||
try {
|
|
||||||
await removeRepoMutation.mutateAsync(id);
|
|
||||||
toast.success(
|
|
||||||
t("templates.repo.removeSuccess", {
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
defaultValue: `已移除仓库 ${owner}/${name}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
toast.error(
|
|
||||||
t("templates.repo.removeFailed", { defaultValue: "移除仓库失败" }),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = async (id: number, enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
await toggleRepoMutation.mutateAsync({ id, enabled });
|
|
||||||
toast.success(
|
|
||||||
enabled
|
|
||||||
? t("templates.repo.enableSuccess", { defaultValue: "已启用仓库" })
|
|
||||||
: t("templates.repo.disableSuccess", { defaultValue: "已禁用仓库" }),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
toast.error(
|
|
||||||
t("templates.repo.toggleFailed", {
|
|
||||||
defaultValue: "切换仓库状态失败",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenRepo = async (owner: string, name: string) => {
|
|
||||||
try {
|
|
||||||
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to open URL:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={true}
|
|
||||||
title={t("templates.repo.title", { defaultValue: "模板仓库管理" })}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
{/* 添加仓库表单 */}
|
|
||||||
<div className="space-y-4 glass-card rounded-xl p-6">
|
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
{t("templates.repo.addTitle", { defaultValue: "添加模板仓库" })}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="repo-url" className="text-foreground">
|
|
||||||
{t("templates.repo.url", { defaultValue: "仓库地址" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="repo-url"
|
|
||||||
placeholder={t("templates.repo.urlPlaceholder", {
|
|
||||||
defaultValue: "owner/repo 或 https://github.com/owner/repo",
|
|
||||||
})}
|
|
||||||
value={repoUrl}
|
|
||||||
onChange={(e) => setRepoUrl(e.target.value)}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="branch" className="text-foreground">
|
|
||||||
{t("templates.repo.branch", { defaultValue: "分支" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="branch"
|
|
||||||
placeholder={t("templates.repo.branchPlaceholder", {
|
|
||||||
defaultValue: "main",
|
|
||||||
})}
|
|
||||||
value={branch}
|
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={addRepoMutation.isPending}
|
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{addRepoMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{t("templates.repo.add", { defaultValue: "添加仓库" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 仓库列表 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
{t("templates.repo.list", { defaultValue: "仓库列表" })}
|
|
||||||
</h3>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12 glass-card rounded-xl">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : repos.length === 0 ? (
|
|
||||||
<div className="text-center py-12 glass-card rounded-xl">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("templates.repo.empty", { defaultValue: "暂无仓库" })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{repos.map((repo) => (
|
|
||||||
<div
|
|
||||||
key={repo.id ?? `${repo.owner}-${repo.name}`}
|
|
||||||
className="flex items-center justify-between glass-card rounded-xl px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
||||||
<Switch
|
|
||||||
checked={repo.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
repo.id !== null && handleToggle(repo.id, checked)
|
|
||||||
}
|
|
||||||
disabled={toggleRepoMutation.isPending || repo.id === null}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-foreground">
|
|
||||||
{repo.owner}/{repo.name}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.repo.branch", { defaultValue: "分支" })}:{" "}
|
|
||||||
{repo.branch}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={repo.enabled ? "default" : "secondary"}
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
|
||||||
>
|
|
||||||
{repo.enabled
|
|
||||||
? t("templates.repo.enabled", {
|
|
||||||
defaultValue: "已启用",
|
|
||||||
})
|
|
||||||
: t("templates.repo.disabled", {
|
|
||||||
defaultValue: "已禁用",
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
|
||||||
title={t("common.view", { defaultValue: "查看" })}
|
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
repo.id !== null &&
|
|
||||||
handleRemove(repo.id, repo.owner, repo.name)
|
|
||||||
}
|
|
||||||
disabled={removeRepoMutation.isPending || repo.id === null}
|
|
||||||
title={t("common.delete", { defaultValue: "删除" })}
|
|
||||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
import { useState, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { RefreshCw, Search, Settings, Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ComponentCard } from "./ComponentCard";
|
|
||||||
import { ComponentDetail } from "./ComponentDetail";
|
|
||||||
import { CategoryFilter } from "./CategoryFilter";
|
|
||||||
import { RepoManager } from "./RepoManager";
|
|
||||||
import { BundleList } from "./BundleList";
|
|
||||||
import {
|
|
||||||
useTemplateComponents,
|
|
||||||
useComponentCategories,
|
|
||||||
useInstallTemplateComponent,
|
|
||||||
useUninstallTemplateComponent,
|
|
||||||
useRefreshTemplateIndex,
|
|
||||||
} from "@/lib/query/template";
|
|
||||||
import type { ComponentType, TemplateComponent } from "@/types/template";
|
|
||||||
import type { AppType } from "@/lib/api/config";
|
|
||||||
|
|
||||||
interface TemplatesPageProps {
|
|
||||||
activeApp: AppType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplatesPage({ activeApp }: TemplatesPageProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [selectedType, setSelectedType] = useState<ComponentType | "bundle">(
|
|
||||||
"bundle",
|
|
||||||
);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
|
||||||
const [detailComponent, setDetailComponent] = useState<
|
|
||||||
TemplateComponent | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
// Queries
|
|
||||||
const {
|
|
||||||
data: componentsData,
|
|
||||||
isLoading: componentsLoading,
|
|
||||||
refetch: refetchComponents,
|
|
||||||
} = useTemplateComponents({
|
|
||||||
componentType: selectedType === "bundle" ? undefined : selectedType,
|
|
||||||
category: selectedCategory,
|
|
||||||
search: searchQuery || undefined,
|
|
||||||
appType: activeApp,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: categories = [] } = useComponentCategories(
|
|
||||||
selectedType === "bundle" ? undefined : selectedType,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const installMutation = useInstallTemplateComponent();
|
|
||||||
const uninstallMutation = useUninstallTemplateComponent();
|
|
||||||
const refreshMutation = useRefreshTemplateIndex();
|
|
||||||
|
|
||||||
const handleInstall = async (id: number, name: string) => {
|
|
||||||
try {
|
|
||||||
await installMutation.mutateAsync({ id, appType: activeApp });
|
|
||||||
toast.success(
|
|
||||||
t("templates.installSuccess", { name, defaultValue: `已安装 ${name}` }),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(
|
|
||||||
t("templates.installFailed", {
|
|
||||||
name,
|
|
||||||
defaultValue: `安装 ${name} 失败`,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.error("Install component failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstall = async (id: number, name: string) => {
|
|
||||||
try {
|
|
||||||
await uninstallMutation.mutateAsync({ id, appType: activeApp });
|
|
||||||
toast.success(
|
|
||||||
t("templates.uninstallSuccess", {
|
|
||||||
name,
|
|
||||||
defaultValue: `已卸载 ${name}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(
|
|
||||||
t("templates.uninstallFailed", {
|
|
||||||
name,
|
|
||||||
defaultValue: `卸载 ${name} 失败`,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.error("Uninstall component failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
try {
|
|
||||||
await refreshMutation.mutateAsync();
|
|
||||||
await refetchComponents();
|
|
||||||
toast.success(
|
|
||||||
t("templates.refreshSuccess", { defaultValue: "刷新成功" }),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(t("templates.refreshFailed", { defaultValue: "刷新失败" }), {
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
console.error("Refresh index failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const components = componentsData?.items || [];
|
|
||||||
|
|
||||||
// 过滤组件
|
|
||||||
const filteredComponents = useMemo(() => {
|
|
||||||
if (!searchQuery.trim()) return components;
|
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return components.filter((component) => {
|
|
||||||
const name = component.name?.toLowerCase() || "";
|
|
||||||
const description = component.description?.toLowerCase() || "";
|
|
||||||
const category = component.category?.toLowerCase() || "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
name.includes(query) ||
|
|
||||||
description.includes(query) ||
|
|
||||||
category.includes(query)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [components, searchQuery]);
|
|
||||||
|
|
||||||
const componentTypeOptions: {
|
|
||||||
value: ComponentType | "bundle";
|
|
||||||
icon: string;
|
|
||||||
}[] = [
|
|
||||||
{ value: "bundle", icon: "📦" },
|
|
||||||
{ value: "agent", icon: "🤖" },
|
|
||||||
{ value: "command", icon: "⚡" },
|
|
||||||
{ value: "mcp", icon: "🔌" },
|
|
||||||
{ value: "setting", icon: "⚙️" },
|
|
||||||
{ value: "hook", icon: "🪝" },
|
|
||||||
{ value: "skill", icon: "💡" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-[80rem] px-6 flex h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
|
|
||||||
{/* 左侧分类过滤器 */}
|
|
||||||
<div className="w-48 shrink-0 mr-6 overflow-y-auto">
|
|
||||||
<CategoryFilter
|
|
||||||
categories={categories}
|
|
||||||
selectedCategory={selectedCategory}
|
|
||||||
onSelectCategory={setSelectedCategory}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧主内容区 */}
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{/* 顶部工具栏 */}
|
|
||||||
<div className="mb-6 space-y-4">
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex items-center justify-end gap-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={refreshMutation.isPending}
|
|
||||||
>
|
|
||||||
{refreshMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{t("templates.refresh", { defaultValue: "刷新索引" })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setRepoManagerOpen(true)}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
{t("templates.manageRepos", { defaultValue: "管理仓库" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索框 */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("templates.searchPlaceholder", {
|
|
||||||
defaultValue: "搜索组件...",
|
|
||||||
})}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-9 pr-3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 类型标签页 */}
|
|
||||||
<Tabs
|
|
||||||
value={selectedType}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSelectedType(value as ComponentType | "bundle");
|
|
||||||
setSelectedCategory(undefined);
|
|
||||||
}}
|
|
||||||
className="flex-1 flex flex-col overflow-hidden"
|
|
||||||
>
|
|
||||||
<TabsList className="w-full justify-start mb-4">
|
|
||||||
{componentTypeOptions.map((option) => (
|
|
||||||
<TabsTrigger key={option.value} value={option.value}>
|
|
||||||
<span className="mr-1.5">{option.icon}</span>
|
|
||||||
{t(`templates.type.${option.value}`, {
|
|
||||||
defaultValue: option.value,
|
|
||||||
})}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* 组合标签页 */}
|
|
||||||
<TabsContent value="bundle" className="flex-1 overflow-y-auto mt-0">
|
|
||||||
<div className="py-4">
|
|
||||||
<BundleList selectedApp={activeApp} />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 组件类型标签页 */}
|
|
||||||
{componentTypeOptions
|
|
||||||
.filter((opt) => opt.value !== "bundle")
|
|
||||||
.map((option) => (
|
|
||||||
<TabsContent
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className="flex-1 overflow-y-auto mt-0"
|
|
||||||
>
|
|
||||||
<div className="py-4">
|
|
||||||
{componentsLoading ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : filteredComponents.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
|
||||||
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("templates.empty", { defaultValue: "暂无组件" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("templates.emptyDescription", {
|
|
||||||
defaultValue: "请添加模板仓库来获取组件",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{searchQuery && (
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
|
||||||
{t("templates.count", {
|
|
||||||
count: filteredComponents.length,
|
|
||||||
defaultValue: `找到 ${filteredComponents.length} 个组件`,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filteredComponents.map((component) => (
|
|
||||||
<ComponentCard
|
|
||||||
key={
|
|
||||||
component.id ??
|
|
||||||
`${component.repoId}-${component.path}`
|
|
||||||
}
|
|
||||||
component={component}
|
|
||||||
onInstall={async () => {
|
|
||||||
if (component.id !== null) {
|
|
||||||
await handleInstall(
|
|
||||||
component.id,
|
|
||||||
component.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onUninstall={async () => {
|
|
||||||
if (component.id !== null) {
|
|
||||||
await handleUninstall(
|
|
||||||
component.id,
|
|
||||||
component.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onViewDetail={() => setDetailComponent(component)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 仓库管理弹窗 */}
|
|
||||||
{repoManagerOpen && (
|
|
||||||
<RepoManager onClose={() => setRepoManagerOpen(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 组件详情弹窗 */}
|
|
||||||
{detailComponent && detailComponent.id !== null && (
|
|
||||||
<ComponentDetail
|
|
||||||
componentId={detailComponent.id}
|
|
||||||
selectedApp={activeApp}
|
|
||||||
onClose={() => setDetailComponent(undefined)}
|
|
||||||
onInstall={handleInstall}
|
|
||||||
onUninstall={handleUninstall}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { TemplatesPage } from "./TemplatesPage";
|
|
||||||
export { ComponentCard } from "./ComponentCard";
|
|
||||||
export { ComponentDetail } from "./ComponentDetail";
|
|
||||||
export { CategoryFilter } from "./CategoryFilter";
|
|
||||||
export { RepoManager } from "./RepoManager";
|
|
||||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|||||||
"bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
"bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||||
// 危险按钮:红底白字(对应旧版 danger)
|
// 危险按钮:红底白字(对应旧版 danger)
|
||||||
destructive:
|
destructive:
|
||||||
"bg-red-500 text-white hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600",
|
"bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700",
|
||||||
// 轮廓按钮
|
// 轮廓按钮
|
||||||
outline:
|
outline:
|
||||||
"border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800",
|
"border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Save, Loader2 } from "lucide-react";
|
import { Save, Loader2 } from "lucide-react";
|
||||||
@@ -26,7 +25,6 @@ export function ModelTestConfigPanel() {
|
|||||||
claudeModel: "claude-haiku-4-5-20251001",
|
claudeModel: "claude-haiku-4-5-20251001",
|
||||||
codexModel: "gpt-5.1-codex@low",
|
codexModel: "gpt-5.1-codex@low",
|
||||||
geminiModel: "gemini-3-pro-preview",
|
geminiModel: "gemini-3-pro-preview",
|
||||||
testPrompt: "Who are you?",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,7 +43,6 @@ export function ModelTestConfigPanel() {
|
|||||||
claudeModel: data.claudeModel,
|
claudeModel: data.claudeModel,
|
||||||
codexModel: data.codexModel,
|
codexModel: data.codexModel,
|
||||||
geminiModel: data.geminiModel,
|
geminiModel: data.geminiModel,
|
||||||
testPrompt: data.testPrompt || "Who are you?",
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
@@ -69,7 +66,6 @@ export function ModelTestConfigPanel() {
|
|||||||
claudeModel: config.claudeModel,
|
claudeModel: config.claudeModel,
|
||||||
codexModel: config.codexModel,
|
codexModel: config.codexModel,
|
||||||
geminiModel: config.geminiModel,
|
geminiModel: config.geminiModel,
|
||||||
testPrompt: config.testPrompt || "Who are you?",
|
|
||||||
};
|
};
|
||||||
await saveStreamCheckConfig(parsed);
|
await saveStreamCheckConfig(parsed);
|
||||||
toast.success(t("streamCheck.configSaved"), {
|
toast.success(t("streamCheck.configSaved"), {
|
||||||
@@ -193,21 +189,6 @@ export function ModelTestConfigPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -202,7 +202,6 @@ export function PricingConfigPanel() {
|
|||||||
|
|
||||||
{editingModel && (
|
{editingModel && (
|
||||||
<PricingEditModal
|
<PricingEditModal
|
||||||
open={!!editingModel}
|
|
||||||
model={editingModel}
|
model={editingModel}
|
||||||
isNew={isAddingNew}
|
isNew={isAddingNew}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Save, Plus } from "lucide-react";
|
import {
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -10,14 +15,12 @@ import { useUpdateModelPricing } from "@/lib/query/usage";
|
|||||||
import type { ModelPricing } from "@/types/usage";
|
import type { ModelPricing } from "@/types/usage";
|
||||||
|
|
||||||
interface PricingEditModalProps {
|
interface PricingEditModalProps {
|
||||||
open: boolean;
|
|
||||||
model: ModelPricing;
|
model: ModelPricing;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PricingEditModal({
|
export function PricingEditModal({
|
||||||
open,
|
|
||||||
model,
|
model,
|
||||||
isNew = false,
|
isNew = false,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -83,142 +86,139 @@ export function PricingEditModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FullScreenPanel
|
<Dialog open onOpenChange={onClose}>
|
||||||
isOpen={open}
|
<DialogContent>
|
||||||
title={
|
<DialogHeader>
|
||||||
isNew
|
<DialogTitle>
|
||||||
? t("usage.addPricing", "新增定价")
|
{isNew
|
||||||
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`
|
? t("usage.addPricing", "新增定价")
|
||||||
}
|
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`}
|
||||||
onClose={onClose}
|
</DialogTitle>
|
||||||
footer={
|
</DialogHeader>
|
||||||
<Button
|
|
||||||
type="submit"
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
form="pricing-form"
|
{isNew && (
|
||||||
disabled={updatePricing.isPending}
|
<div className="space-y-2">
|
||||||
>
|
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
|
||||||
{isNew ? (
|
<Input
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
id="modelId"
|
||||||
) : (
|
value={formData.modelId}
|
||||||
<Save className="h-4 w-4 mr-2" />
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, modelId: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("usage.modelIdPlaceholder", {
|
||||||
|
defaultValue: "例如: claude-3-5-sonnet-20241022",
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
|
<Label htmlFor="displayName">
|
||||||
|
{t("usage.displayName", "显示名称")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="modelId"
|
id="displayName"
|
||||||
value={formData.modelId}
|
value={formData.displayName}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, modelId: e.target.value })
|
setFormData({ ...formData, displayName: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder={t("usage.modelIdPlaceholder", {
|
placeholder={t("usage.displayNamePlaceholder", {
|
||||||
defaultValue: "例如: claude-3-5-sonnet-20241022",
|
defaultValue: "例如: Claude 3.5 Sonnet",
|
||||||
})}
|
})}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="displayName">
|
<Label htmlFor="inputCost">
|
||||||
{t("usage.displayName", "显示名称")}
|
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="displayName"
|
id="inputCost"
|
||||||
value={formData.displayName}
|
type="number"
|
||||||
onChange={(e) =>
|
step="0.01"
|
||||||
setFormData({ ...formData, displayName: e.target.value })
|
min="0"
|
||||||
}
|
value={formData.inputCost}
|
||||||
placeholder={t("usage.displayNamePlaceholder", {
|
onChange={(e) =>
|
||||||
defaultValue: "例如: Claude 3.5 Sonnet",
|
setFormData({ ...formData, inputCost: e.target.value })
|
||||||
})}
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="inputCost">
|
<Label htmlFor="outputCost">
|
||||||
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
|
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="inputCost"
|
id="outputCost"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
value={formData.inputCost}
|
value={formData.outputCost}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, inputCost: e.target.value })
|
setFormData({ ...formData, outputCost: e.target.value })
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="outputCost">
|
<Label htmlFor="cacheReadCost">
|
||||||
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
|
{t(
|
||||||
</Label>
|
"usage.cacheReadCostPerMillion",
|
||||||
<Input
|
"缓存读取成本 (每百万 tokens, USD)",
|
||||||
id="outputCost"
|
)}
|
||||||
type="number"
|
</Label>
|
||||||
step="0.01"
|
<Input
|
||||||
min="0"
|
id="cacheReadCost"
|
||||||
value={formData.outputCost}
|
type="number"
|
||||||
onChange={(e) =>
|
step="0.01"
|
||||||
setFormData({ ...formData, outputCost: e.target.value })
|
min="0"
|
||||||
}
|
value={formData.cacheReadCost}
|
||||||
required
|
onChange={(e) =>
|
||||||
/>
|
setFormData({ ...formData, cacheReadCost: e.target.value })
|
||||||
</div>
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cacheReadCost">
|
<Label htmlFor="cacheCreationCost">
|
||||||
{t(
|
{t(
|
||||||
"usage.cacheReadCostPerMillion",
|
"usage.cacheCreationCostPerMillion",
|
||||||
"缓存读取成本 (每百万 tokens, USD)",
|
"缓存写入成本 (每百万 tokens, USD)",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cacheReadCost"
|
id="cacheCreationCost"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
value={formData.cacheReadCost}
|
value={formData.cacheCreationCost}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, cacheReadCost: e.target.value })
|
setFormData({ ...formData, cacheCreationCost: e.target.value })
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<DialogFooter>
|
||||||
<Label htmlFor="cacheCreationCost">
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
{t(
|
{t("common.cancel", "取消")}
|
||||||
"usage.cacheCreationCostPerMillion",
|
</Button>
|
||||||
"缓存写入成本 (每百万 tokens, USD)",
|
<Button type="submit" disabled={updatePricing.isPending}>
|
||||||
)}
|
{updatePricing.isPending
|
||||||
</Label>
|
? t("common.saving", "保存中...")
|
||||||
<Input
|
: isNew
|
||||||
id="cacheCreationCost"
|
? t("common.add", "新增")
|
||||||
type="number"
|
: t("common.save", "保存")}
|
||||||
step="0.01"
|
</Button>
|
||||||
min="0"
|
</DialogFooter>
|
||||||
value={formData.cacheCreationCost}
|
</form>
|
||||||
onChange={(e) =>
|
</DialogContent>
|
||||||
setFormData({ ...formData, cacheCreationCost: e.target.value })
|
</Dialog>
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,16 +190,6 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"title": "Data Management",
|
"title": "Data Management",
|
||||||
"description": "Import/export configurations and backup/restore"
|
"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",
|
"language": "Language",
|
||||||
@@ -1046,89 +1036,6 @@
|
|||||||
"circuitOpen": "Circuit Open",
|
"circuitOpen": "Circuit Open",
|
||||||
"consecutiveFailures": "{{count}} consecutive failures"
|
"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": {
|
"proxy": {
|
||||||
"panel": {
|
"panel": {
|
||||||
"serviceAddress": "Service Address",
|
"serviceAddress": "Service Address",
|
||||||
@@ -1285,8 +1192,7 @@
|
|||||||
"checkParams": "Check Parameters",
|
"checkParams": "Check Parameters",
|
||||||
"timeout": "Timeout (seconds)",
|
"timeout": "Timeout (seconds)",
|
||||||
"maxRetries": "Max Retries",
|
"maxRetries": "Max Retries",
|
||||||
"degradedThreshold": "Degraded Threshold (ms)",
|
"degradedThreshold": "Degraded Threshold (ms)"
|
||||||
"testPrompt": "Test Prompt"
|
|
||||||
},
|
},
|
||||||
"proxyConfig": {
|
"proxyConfig": {
|
||||||
"proxyEnabled": "Proxy Enabled",
|
"proxyEnabled": "Proxy Enabled",
|
||||||
|
|||||||
@@ -190,16 +190,6 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"title": "データ管理",
|
"title": "データ管理",
|
||||||
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
||||||
},
|
|
||||||
"rectifier": {
|
|
||||||
"title": "整流器",
|
|
||||||
"description": "API リクエストの互換性問題を自動修正",
|
|
||||||
"enabled": "整流器を有効化",
|
|
||||||
"enabledDescription": "マスタースイッチ、オフにするとすべての整流機能が無効になります",
|
|
||||||
"requestGroup": "リクエスト整流",
|
|
||||||
"responseGroup": "レスポンス整流",
|
|
||||||
"thinkingSignature": "Thinking 署名整流",
|
|
||||||
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
@@ -1039,89 +1029,6 @@
|
|||||||
"agents": {
|
"agents": {
|
||||||
"title": "エージェント"
|
"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": {
|
"health": {
|
||||||
"operational": "正常",
|
"operational": "正常",
|
||||||
"degraded": "低下",
|
"degraded": "低下",
|
||||||
@@ -1279,8 +1186,7 @@
|
|||||||
"checkParams": "チェックパラメーター",
|
"checkParams": "チェックパラメーター",
|
||||||
"timeout": "タイムアウト(秒)",
|
"timeout": "タイムアウト(秒)",
|
||||||
"maxRetries": "最大リトライ回数",
|
"maxRetries": "最大リトライ回数",
|
||||||
"degradedThreshold": "劣化しきい値(ミリ秒)",
|
"degradedThreshold": "劣化しきい値(ミリ秒)"
|
||||||
"testPrompt": "テストプロンプト"
|
|
||||||
},
|
},
|
||||||
"proxyConfig": {
|
"proxyConfig": {
|
||||||
"proxyEnabled": "プロキシ有効",
|
"proxyEnabled": "プロキシ有効",
|
||||||
|
|||||||
@@ -190,16 +190,6 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"title": "数据管理",
|
"title": "数据管理",
|
||||||
"description": "导入导出配置与备份恢复"
|
"description": "导入导出配置与备份恢复"
|
||||||
},
|
|
||||||
"rectifier": {
|
|
||||||
"title": "整流器",
|
|
||||||
"description": "自动修复 API 请求中的兼容性问题",
|
|
||||||
"enabled": "启用整流器",
|
|
||||||
"enabledDescription": "总开关,关闭后所有整流功能将被禁用",
|
|
||||||
"requestGroup": "请求整流",
|
|
||||||
"responseGroup": "响应整流",
|
|
||||||
"thinkingSignature": "Thinking 签名整流",
|
|
||||||
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"language": "界面语言",
|
"language": "界面语言",
|
||||||
@@ -1046,89 +1036,6 @@
|
|||||||
"circuitOpen": "熔断",
|
"circuitOpen": "熔断",
|
||||||
"consecutiveFailures": "连续失败 {{count}} 次"
|
"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": {
|
"proxy": {
|
||||||
"panel": {
|
"panel": {
|
||||||
"serviceAddress": "服务地址",
|
"serviceAddress": "服务地址",
|
||||||
@@ -1285,8 +1192,7 @@
|
|||||||
"checkParams": "检查参数",
|
"checkParams": "检查参数",
|
||||||
"timeout": "超时时间(秒)",
|
"timeout": "超时时间(秒)",
|
||||||
"maxRetries": "最大重试次数",
|
"maxRetries": "最大重试次数",
|
||||||
"degradedThreshold": "降级阈值(毫秒)",
|
"degradedThreshold": "降级阈值(毫秒)"
|
||||||
"testPrompt": "检查提示词"
|
|
||||||
},
|
},
|
||||||
"proxyConfig": {
|
"proxyConfig": {
|
||||||
"proxyEnabled": "代理总开关",
|
"proxyEnabled": "代理总开关",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface StreamCheckConfig {
|
|||||||
claudeModel: string;
|
claudeModel: string;
|
||||||
codexModel: string;
|
codexModel: string;
|
||||||
geminiModel: string;
|
geminiModel: string;
|
||||||
testPrompt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamCheckResult {
|
export interface StreamCheckResult {
|
||||||
|
|||||||
@@ -134,17 +134,4 @@ export const settingsApi = {
|
|||||||
> {
|
> {
|
||||||
return await invoke("get_tool_versions");
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
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");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// 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[];
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user