mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c39ce857ab | |||
| 26acb5f7c0 | |||
| d403a28c73 | |||
| a8f9562758 | |||
| d83312ad3f | |||
| cfbbc9485f | |||
| 4c884a1256 | |||
| 0210e7991f | |||
| ddc027d854 | |||
| afec14b913 | |||
| 94da8ca89d | |||
| e093164b8d | |||
| d57da0b700 | |||
| 65a76a7ce4 | |||
| 2a4fcc46ff |
+53
-131
@@ -69,6 +69,8 @@ pub struct ToolVersion {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
let tools = vec!["claude", "codex", "gemini"];
|
let tools = vec!["claude", "codex", "gemini"];
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
@@ -79,24 +81,47 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
// 1. 获取本地版本 (保持不变)
|
||||||
let (local_version, local_error) = {
|
let (local_version, local_error) = {
|
||||||
// 先尝试直接执行
|
let output = if cfg!(target_os = "windows") {
|
||||||
let direct_result = try_get_version(tool);
|
Command::new("cmd")
|
||||||
|
.args(["/C", &format!("{tool} --version")])
|
||||||
if direct_result.0.is_some() {
|
.output()
|
||||||
direct_result
|
|
||||||
} else {
|
} else {
|
||||||
// 扫描常见的 npm 全局安装路径
|
Command::new("sh")
|
||||||
scan_cli_version(tool)
|
.arg("-c")
|
||||||
|
.arg(format!("{tool} --version"))
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(out) => {
|
||||||
|
if out.status.success() {
|
||||||
|
(
|
||||||
|
Some(String::from_utf8_lossy(&out.stdout).trim().to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some(if err.is_empty() {
|
||||||
|
"未安装或无法执行".to_string()
|
||||||
|
} else {
|
||||||
|
err
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => (None, Some(e.to_string())),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. 获取远程最新版本
|
// 2. 获取远程最新版本
|
||||||
let latest_version = match tool {
|
let latest_version = match tool {
|
||||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
"codex" => fetch_github_latest_release(&client, "openai/openai-python").await,
|
||||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await, // 修正:使用 npm 官方包 @google/gemini-cli
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,6 +136,24 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
|||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper function to fetch latest version from GitHub Release
|
||||||
|
async fn fetch_github_latest_release(client: &reqwest::Client, repo: &str) -> Option<String> {
|
||||||
|
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
|
||||||
|
// GitHub API 需要 user-agent
|
||||||
|
match client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||||
|
json.get("tag_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper function to fetch latest version from npm registry
|
/// Helper function to fetch latest version from npm registry
|
||||||
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
|
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
|
||||||
let url = format!("https://registry.npmjs.org/{package}");
|
let url = format!("https://registry.npmjs.org/{package}");
|
||||||
@@ -128,124 +171,3 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
|
|||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从版本输出中提取纯版本号
|
|
||||||
fn extract_version(raw: &str) -> String {
|
|
||||||
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
|
|
||||||
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
|
|
||||||
re.find(raw)
|
|
||||||
.map(|m| m.as_str().to_string())
|
|
||||||
.unwrap_or_else(|| raw.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 尝试直接执行命令获取版本
|
|
||||||
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let output = if cfg!(target_os = "windows") {
|
|
||||||
Command::new("cmd")
|
|
||||||
.args(["/C", &format!("{tool} --version")])
|
|
||||||
.output()
|
|
||||||
} else {
|
|
||||||
Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(format!("{tool} --version"))
|
|
||||||
.output()
|
|
||||||
};
|
|
||||||
|
|
||||||
match output {
|
|
||||||
Ok(out) => {
|
|
||||||
if out.status.success() {
|
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
(Some(extract_version(&raw)), None)
|
|
||||||
} else {
|
|
||||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
Some(if err.is_empty() {
|
|
||||||
"未安装或无法执行".to_string()
|
|
||||||
} else {
|
|
||||||
err
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => (None, Some(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描常见路径查找 CLI
|
|
||||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let home = dirs::home_dir().unwrap_or_default();
|
|
||||||
|
|
||||||
// 常见的 npm 全局安装路径
|
|
||||||
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
|
||||||
home.join(".npm-global/bin"),
|
|
||||||
home.join(".local/bin"),
|
|
||||||
home.join("n/bin"), // n version manager
|
|
||||||
];
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
|
|
||||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
|
||||||
search_paths.push(std::path::PathBuf::from("/usr/bin"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
if let Some(appdata) = dirs::data_dir() {
|
|
||||||
search_paths.push(appdata.join("npm"));
|
|
||||||
}
|
|
||||||
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 扫描 nvm 目录下的所有 node 版本
|
|
||||||
let nvm_base = home.join(".nvm/versions/node");
|
|
||||||
if nvm_base.exists() {
|
|
||||||
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let bin_path = entry.path().join("bin");
|
|
||||||
if bin_path.exists() {
|
|
||||||
search_paths.push(bin_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在每个路径中查找工具
|
|
||||||
for path in &search_paths {
|
|
||||||
let tool_path = if cfg!(target_os = "windows") {
|
|
||||||
path.join(format!("{tool}.cmd"))
|
|
||||||
} else {
|
|
||||||
path.join(tool)
|
|
||||||
};
|
|
||||||
|
|
||||||
if tool_path.exists() {
|
|
||||||
// 构建 PATH 环境变量,确保 node 可被找到
|
|
||||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
|
||||||
let new_path = format!("{}:{}", path.display(), current_path);
|
|
||||||
|
|
||||||
let output = Command::new(&tool_path)
|
|
||||||
.arg("--version")
|
|
||||||
.env("PATH", &new_path)
|
|
||||||
.output();
|
|
||||||
|
|
||||||
if let Ok(out) = output {
|
|
||||||
if out.status.success() {
|
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
return (Some(extract_version(&raw)), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(None, Some("未安装或无法执行".to_string()))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::format_skill_error;
|
use crate::error::format_skill_error;
|
||||||
use crate::services::skill::SkillState;
|
use crate::services::skill::SkillState;
|
||||||
use crate::services::{Skill, SkillRepo, SkillService};
|
use crate::services::{Skill, SkillRepo, SkillService};
|
||||||
@@ -9,46 +8,15 @@ use tauri::State;
|
|||||||
|
|
||||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||||
|
|
||||||
/// 解析 app 参数为 AppType
|
|
||||||
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
|
||||||
match app.to_lowercase().as_str() {
|
|
||||||
"claude" => Ok(AppType::Claude),
|
|
||||||
"codex" => Ok(AppType::Codex),
|
|
||||||
"gemini" => Ok(AppType::Gemini),
|
|
||||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据 app_type 生成带前缀的 skill key
|
|
||||||
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
|
|
||||||
let prefix = match app_type {
|
|
||||||
AppType::Claude => "claude",
|
|
||||||
AppType::Codex => "codex",
|
|
||||||
AppType::Gemini => "gemini",
|
|
||||||
};
|
|
||||||
format!("{prefix}:{directory}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_skills(
|
pub async fn get_skills(
|
||||||
service: State<'_, SkillServiceState>,
|
service: State<'_, SkillServiceState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<Vec<Skill>, String> {
|
) -> Result<Vec<Skill>, String> {
|
||||||
get_skills_for_app("claude".to_string(), service, app_state).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_skills_for_app(
|
|
||||||
app: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<Vec<Skill>, String> {
|
|
||||||
let app_type = parse_app_type(&app)?;
|
|
||||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let skills = service
|
let skills = service
|
||||||
|
.0
|
||||||
.list_skills(repos)
|
.list_skills(repos)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -58,19 +26,16 @@ pub async fn get_skills_for_app(
|
|||||||
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
||||||
|
|
||||||
for skill in &skills {
|
for skill in &skills {
|
||||||
if skill.installed {
|
if skill.installed && !existing_states.contains_key(&skill.directory) {
|
||||||
let key = get_skill_key(&app_type, &skill.directory);
|
// 本地有该 skill,但数据库中没有记录,自动添加
|
||||||
if !existing_states.contains_key(&key) {
|
if let Err(e) = app_state.db.update_skill_state(
|
||||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
&skill.directory,
|
||||||
if let Err(e) = app_state.db.update_skill_state(
|
&SkillState {
|
||||||
&key,
|
installed: true,
|
||||||
&SkillState {
|
installed_at: Utc::now(),
|
||||||
installed: true,
|
},
|
||||||
installed_at: Utc::now(),
|
) {
|
||||||
},
|
log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
|
||||||
) {
|
|
||||||
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,23 +49,11 @@ pub async fn install_skill(
|
|||||||
service: State<'_, SkillServiceState>,
|
service: State<'_, SkillServiceState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
install_skill_for_app("claude".to_string(), directory, service, app_state).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn install_skill_for_app(
|
|
||||||
app: String,
|
|
||||||
directory: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = parse_app_type(&app)?;
|
|
||||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let skills = service
|
let skills = service
|
||||||
|
.0
|
||||||
.list_skills(repos)
|
.list_skills(repos)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -140,16 +93,16 @@ pub async fn install_skill_for_app(
|
|||||||
};
|
};
|
||||||
|
|
||||||
service
|
service
|
||||||
|
.0
|
||||||
.install_skill(directory.clone(), repo)
|
.install_skill(directory.clone(), repo)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = get_skill_key(&app_type, &directory);
|
|
||||||
app_state
|
app_state
|
||||||
.db
|
.db
|
||||||
.update_skill_state(
|
.update_skill_state(
|
||||||
&key,
|
&directory,
|
||||||
&SkillState {
|
&SkillState {
|
||||||
installed: true,
|
installed: true,
|
||||||
installed_at: Utc::now(),
|
installed_at: Utc::now(),
|
||||||
@@ -166,29 +119,16 @@ pub fn uninstall_skill(
|
|||||||
service: State<'_, SkillServiceState>,
|
service: State<'_, SkillServiceState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn uninstall_skill_for_app(
|
|
||||||
app: String,
|
|
||||||
directory: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = parse_app_type(&app)?;
|
|
||||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
service
|
service
|
||||||
|
.0
|
||||||
.uninstall_skill(directory.clone())
|
.uninstall_skill(directory.clone())
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Remove from database by setting installed = false
|
// Remove from database by setting installed = false
|
||||||
let key = get_skill_key(&app_type, &directory);
|
|
||||||
app_state
|
app_state
|
||||||
.db
|
.db
|
||||||
.update_skill_state(
|
.update_skill_state(
|
||||||
&key,
|
&directory,
|
||||||
&SkillState {
|
&SkillState {
|
||||||
installed: false,
|
installed: false,
|
||||||
installed_at: Utc::now(),
|
installed_at: Utc::now(),
|
||||||
|
|||||||
@@ -13,22 +13,18 @@ impl Database {
|
|||||||
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
|
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
let skill_iter = stmt
|
let skill_iter = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
let directory: String = row.get(0)?;
|
let key: String = row.get(0)?;
|
||||||
let app_type: String = row.get(1)?;
|
let installed: bool = row.get(1)?;
|
||||||
let installed: bool = row.get(2)?;
|
let installed_at_ts: i64 = row.get(2)?;
|
||||||
let installed_at_ts: i64 = row.get(3)?;
|
|
||||||
|
|
||||||
let installed_at =
|
let installed_at =
|
||||||
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
||||||
|
|
||||||
// 构建复合 key:"app_type:directory"
|
|
||||||
let key = format!("{app_type}:{directory}");
|
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
key,
|
key,
|
||||||
SkillState {
|
SkillState {
|
||||||
@@ -48,21 +44,11 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 更新 Skill 状态
|
/// 更新 Skill 状态
|
||||||
/// key 格式为 "app_type:directory"
|
|
||||||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
||||||
// 解析 key
|
|
||||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
|
||||||
let (app, dir) = key.split_at(idx);
|
|
||||||
(app, &dir[1..]) // 跳过冒号
|
|
||||||
} else {
|
|
||||||
// 向后兼容:如果没有前缀,默认为 claude
|
|
||||||
("claude", key)
|
|
||||||
};
|
|
||||||
|
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||||
params![directory, app_type, state.installed, state.installed_at.timestamp()],
|
params![key, state.installed, state.installed_at.timestamp()],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -96,11 +96,9 @@ impl Database {
|
|||||||
// 5. Skills 表
|
// 5. Skills 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS skills (
|
"CREATE TABLE IF NOT EXISTS skills (
|
||||||
directory TEXT NOT NULL,
|
key TEXT PRIMARY KEY,
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
installed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
installed_at INTEGER NOT NULL DEFAULT 0
|
||||||
PRIMARY KEY (directory, app_type)
|
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -393,9 +391,7 @@ impl Database {
|
|||||||
Self::set_user_version(conn, 1)?;
|
Self::set_user_version(conn, 1)?;
|
||||||
}
|
}
|
||||||
1 => {
|
1 => {
|
||||||
log::info!(
|
log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)");
|
||||||
"迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
|
|
||||||
);
|
|
||||||
Self::migrate_v1_to_v2(conn)?;
|
Self::migrate_v1_to_v2(conn)?;
|
||||||
Self::set_user_version(conn, 2)?;
|
Self::set_user_version(conn, 2)?;
|
||||||
}
|
}
|
||||||
@@ -484,7 +480,7 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表
|
/// v1 -> v2 迁移:添加使用统计表和完整字段
|
||||||
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
||||||
// providers 表字段
|
// providers 表字段
|
||||||
Self::add_column_if_missing(
|
Self::add_column_if_missing(
|
||||||
@@ -580,82 +576,6 @@ impl Database {
|
|||||||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||||||
Self::seed_model_pricing(conn)?;
|
Self::seed_model_pricing(conn)?;
|
||||||
|
|
||||||
// 重构 skills 表(添加 app_type 字段)
|
|
||||||
Self::migrate_skills_table(conn)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
|
|
||||||
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
// 检查是否已经是新表结构
|
|
||||||
if Self::has_column(conn, "skills", "app_type")? {
|
|
||||||
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("开始迁移 skills 表...");
|
|
||||||
|
|
||||||
// 1. 重命名旧表
|
|
||||||
conn.execute("ALTER TABLE skills RENAME TO skills_old", [])
|
|
||||||
.map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 2. 创建新表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE skills (
|
|
||||||
directory TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (directory, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 3. 迁移数据:解析 key 格式(如 "claude:my-skill" 或 "codex:foo")
|
|
||||||
// 旧数据如果没有前缀,默认为 claude
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT key, installed, installed_at FROM skills_old")
|
|
||||||
.map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?;
|
|
||||||
|
|
||||||
let old_skills: Vec<(String, bool, i64)> = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok((
|
|
||||||
row.get::<_, String>(0)?,
|
|
||||||
row.get::<_, bool>(1)?,
|
|
||||||
row.get::<_, i64>(2)?,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?;
|
|
||||||
|
|
||||||
let count = old_skills.len();
|
|
||||||
|
|
||||||
for (key, installed, installed_at) in old_skills {
|
|
||||||
// 解析 key: "app:directory" 或 "directory"(默认 claude)
|
|
||||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
|
||||||
let (app, dir) = key.split_at(idx);
|
|
||||||
(app.to_string(), dir[1..].to_string()) // 跳过冒号
|
|
||||||
} else {
|
|
||||||
("claude".to_string(), key.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
rusqlite::params![directory, app_type, installed, installed_at],
|
|
||||||
)
|
|
||||||
.map_err(|e| {
|
|
||||||
AppError::Database(format!("迁移 skill {key} 到新表失败: {e}"))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 删除旧表
|
|
||||||
conn.execute("DROP TABLE skills_old", [])
|
|
||||||
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
|
|
||||||
|
|
||||||
log::info!("skills 表迁移完成,共迁移 {count} 条记录");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -635,11 +635,8 @@ pub fn run() {
|
|||||||
commands::restore_env_backup,
|
commands::restore_env_backup,
|
||||||
// Skill management
|
// Skill management
|
||||||
commands::get_skills,
|
commands::get_skills,
|
||||||
commands::get_skills_for_app,
|
|
||||||
commands::install_skill,
|
commands::install_skill,
|
||||||
commands::install_skill_for_app,
|
|
||||||
commands::uninstall_skill,
|
commands::uninstall_skill,
|
||||||
commands::uninstall_skill_for_app,
|
|
||||||
commands::get_skill_repos,
|
commands::get_skill_repos,
|
||||||
commands::add_skill_repo,
|
commands::add_skill_repo,
|
||||||
commands::remove_skill_repo,
|
commands::remove_skill_repo,
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_provider_router_creation() {
|
async fn test_provider_router_creation() {
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::new_in_memory().unwrap());
|
||||||
let router = ProviderRouter::new(db);
|
let router = ProviderRouter::new(db);
|
||||||
|
|
||||||
// 测试创建熔断器
|
// 测试创建熔断器
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::format_skill_error;
|
use crate::error::format_skill_error;
|
||||||
|
|
||||||
/// 技能对象
|
/// 技能对象
|
||||||
@@ -107,16 +106,11 @@ pub struct SkillMetadata {
|
|||||||
pub struct SkillService {
|
pub struct SkillService {
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
install_dir: PathBuf,
|
install_dir: PathBuf,
|
||||||
app_type: AppType,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkillService {
|
impl SkillService {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
Self::new_for_app(AppType::Claude)
|
let install_dir = Self::get_install_dir()?;
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_for_app(app_type: AppType) -> Result<Self> {
|
|
||||||
let install_dir = Self::get_install_dir_for_app(&app_type)?;
|
|
||||||
|
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
fs::create_dir_all(&install_dir)?;
|
fs::create_dir_all(&install_dir)?;
|
||||||
@@ -128,38 +122,16 @@ impl SkillService {
|
|||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()?,
|
.build()?,
|
||||||
install_dir,
|
install_dir,
|
||||||
app_type,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_install_dir_for_app(app_type: &AppType) -> Result<PathBuf> {
|
fn get_install_dir() -> Result<PathBuf> {
|
||||||
let home = dirs::home_dir().context(format_skill_error(
|
let home = dirs::home_dir().context(format_skill_error(
|
||||||
"GET_HOME_DIR_FAILED",
|
"GET_HOME_DIR_FAILED",
|
||||||
&[],
|
&[],
|
||||||
Some("checkPermission"),
|
Some("checkPermission"),
|
||||||
))?;
|
))?;
|
||||||
|
Ok(home.join(".claude").join("skills"))
|
||||||
let dir = match app_type {
|
|
||||||
AppType::Claude => home.join(".claude").join("skills"),
|
|
||||||
AppType::Codex => {
|
|
||||||
// 检查是否有自定义 Codex 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
|
||||||
custom.join("skills")
|
|
||||||
} else {
|
|
||||||
home.join(".codex").join("skills")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AppType::Gemini => {
|
|
||||||
// 为 Gemini 预留,暂时使用默认路径
|
|
||||||
home.join(".gemini").join("skills")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn app_type(&self) -> &AppType {
|
|
||||||
&self.app_type
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-464
@@ -3,7 +3,6 @@ use rquickjs::{Context, Function, Runtime};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use url::{Host, Url};
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@@ -16,14 +15,20 @@ pub async fn execute_usage_script(
|
|||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
) -> Result<Value, AppError> {
|
) -> Result<Value, AppError> {
|
||||||
// 1. 替换模板变量,避免泄露敏感信息
|
// 1. 替换变量
|
||||||
let script_with_vars =
|
let mut replaced = script_code
|
||||||
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
.replace("{{apiKey}}", api_key)
|
||||||
|
.replace("{{baseUrl}}", base_url);
|
||||||
|
|
||||||
// 2. 验证 base_url 的安全性
|
// 替换 accessToken 和 userId
|
||||||
validate_base_url(base_url)?;
|
if let Some(token) = access_token {
|
||||||
|
replaced = replaced.replace("{{accessToken}}", token);
|
||||||
|
}
|
||||||
|
if let Some(uid) = user_id {
|
||||||
|
replaced = replaced.replace("{{userId}}", uid);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||||
let request_config = {
|
let request_config = {
|
||||||
let runtime = Runtime::new().map_err(|e| {
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -42,7 +47,7 @@ pub async fn execute_usage_script(
|
|||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 执行用户代码,获取配置对象
|
// 执行用户代码,获取配置对象
|
||||||
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_parse_failed",
|
"usage_script.config_parse_failed",
|
||||||
format!("解析配置失败: {e}"),
|
format!("解析配置失败: {e}"),
|
||||||
@@ -89,7 +94,7 @@ pub async fn execute_usage_script(
|
|||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
// 4. 解析 request 配置
|
// 3. 解析 request 配置
|
||||||
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.request_format_invalid",
|
"usage_script.request_format_invalid",
|
||||||
@@ -98,13 +103,10 @@ pub async fn execute_usage_script(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
// 4. 发送 HTTP 请求
|
||||||
validate_request_url(&request.url, base_url)?;
|
|
||||||
|
|
||||||
// 6. 发送 HTTP 请求
|
|
||||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||||
|
|
||||||
// 7. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
// 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
||||||
let result: Value = {
|
let result: Value = {
|
||||||
let runtime = Runtime::new().map_err(|e| {
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -123,7 +125,7 @@ pub async fn execute_usage_script(
|
|||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 重新 eval 获取配置对象
|
// 重新 eval 获取配置对象
|
||||||
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_reparse_failed",
|
"usage_script.config_reparse_failed",
|
||||||
format!("重新解析配置失败: {e}"),
|
format!("重新解析配置失败: {e}"),
|
||||||
@@ -196,7 +198,7 @@ pub async fn execute_usage_script(
|
|||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
// 8. 验证返回值格式
|
// 6. 验证返回值格式
|
||||||
validate_result(&result)?;
|
validate_result(&result)?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -392,451 +394,3 @@ fn validate_single_usage(result: &Value) -> Result<(), AppError> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建替换变量后的脚本,保持与旧版脚本的兼容性
|
|
||||||
fn build_script_with_vars(
|
|
||||||
script_code: &str,
|
|
||||||
api_key: &str,
|
|
||||||
base_url: &str,
|
|
||||||
access_token: Option<&str>,
|
|
||||||
user_id: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
let mut replaced = script_code
|
|
||||||
.replace("{{apiKey}}", api_key)
|
|
||||||
.replace("{{baseUrl}}", base_url);
|
|
||||||
|
|
||||||
if let Some(token) = access_token {
|
|
||||||
replaced = replaced.replace("{{accessToken}}", token);
|
|
||||||
}
|
|
||||||
if let Some(uid) = user_id {
|
|
||||||
replaced = replaced.replace("{{userId}}", uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
replaced
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 验证 base_url 的基本安全性
|
|
||||||
fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
|
||||||
if base_url.is_empty() {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.base_url_empty",
|
|
||||||
"base_url 不能为空",
|
|
||||||
"base_url cannot be empty",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 URL
|
|
||||||
let parsed_url = Url::parse(base_url).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.base_url_invalid",
|
|
||||||
format!("无效的 base_url: {e}"),
|
|
||||||
format!("Invalid base_url: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let is_loopback = is_loopback_host(&parsed_url);
|
|
||||||
|
|
||||||
// 必须是 HTTPS(允许 localhost 用于开发)
|
|
||||||
if parsed_url.scheme() != "https" && !is_loopback {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.base_url_https_required",
|
|
||||||
"base_url 必须使用 HTTPS 协议(localhost 除外)",
|
|
||||||
"base_url must use HTTPS (localhost allowed)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查主机名格式有效性
|
|
||||||
let hostname = parsed_url.host_str().ok_or_else(|| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.base_url_hostname_missing",
|
|
||||||
"base_url 必须包含有效的主机名",
|
|
||||||
"base_url must include a valid hostname",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 基本的主机名格式检查
|
|
||||||
if hostname.is_empty() {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.base_url_hostname_empty",
|
|
||||||
"base_url 主机名不能为空",
|
|
||||||
"base_url hostname cannot be empty",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为明显的私有IP(但在 base_url 阶段不过于严格,主要在 request_url 阶段检查)
|
|
||||||
if is_suspicious_hostname(hostname) {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.base_url_suspicious",
|
|
||||||
"base_url 包含可疑的主机名",
|
|
||||||
"base_url contains a suspicious hostname",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 验证请求 URL 是否安全(防止 SSRF)
|
|
||||||
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
|
|
||||||
// 解析请求 URL
|
|
||||||
let parsed_request = Url::parse(request_url).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.request_url_invalid",
|
|
||||||
format!("无效的请求 URL: {e}"),
|
|
||||||
format!("Invalid request URL: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 解析 base URL
|
|
||||||
let parsed_base = Url::parse(base_url).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.base_url_invalid",
|
|
||||||
format!("无效的 base_url: {e}"),
|
|
||||||
format!("Invalid base_url: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let is_request_loopback = is_loopback_host(&parsed_request);
|
|
||||||
|
|
||||||
// 必须使用 HTTPS(允许 localhost 用于开发)
|
|
||||||
if parsed_request.scheme() != "https" && !is_request_loopback {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.request_https_required",
|
|
||||||
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
|
||||||
"Request URL must use HTTPS (localhost allowed)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
|
||||||
if parsed_request.host_str() != parsed_base.host_str() {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.request_host_mismatch",
|
|
||||||
format!(
|
|
||||||
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
|
||||||
),
|
|
||||||
format!(
|
|
||||||
"Request host {} must match base_url host {} (same-origin required)",
|
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查端口是否匹配(考虑默认端口)
|
|
||||||
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
|
||||||
match (parsed_request.port_or_known_default(), parsed_base.port_or_known_default()) {
|
|
||||||
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
|
||||||
// 端口匹配,继续执行
|
|
||||||
}
|
|
||||||
(Some(request_port), Some(base_port)) => {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.request_port_mismatch",
|
|
||||||
format!(
|
|
||||||
"请求端口 {} 必须与 base_url 端口 {} 匹配",
|
|
||||||
request_port,
|
|
||||||
base_port
|
|
||||||
),
|
|
||||||
format!(
|
|
||||||
"Request port {} must match base_url port {}",
|
|
||||||
request_port,
|
|
||||||
base_port
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.request_port_unknown",
|
|
||||||
"无法确定端口号",
|
|
||||||
"Unable to determine port number",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
|
||||||
if let Some(host) = parsed_request.host_str() {
|
|
||||||
let base_host = parsed_base.host_str().unwrap_or("");
|
|
||||||
|
|
||||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
|
||||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.private_ip_blocked",
|
|
||||||
"禁止访问私有 IP 地址",
|
|
||||||
"Access to private IP addresses is blocked",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否为私有 IP 地址
|
|
||||||
fn is_private_ip(host: &str) -> bool {
|
|
||||||
// localhost 检查
|
|
||||||
if host.eq_ignore_ascii_case("localhost") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试解析为IP地址
|
|
||||||
if let Ok(ip_addr) = host.parse::<std::net::IpAddr>() {
|
|
||||||
return is_private_ip_addr(ip_addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果不是IP地址,不是私有IP
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 使用标准库API检查IP地址是否为私有地址
|
|
||||||
fn is_private_ip_addr(ip: std::net::IpAddr) -> bool {
|
|
||||||
match ip {
|
|
||||||
std::net::IpAddr::V4(ipv4) => {
|
|
||||||
let octets = ipv4.octets();
|
|
||||||
|
|
||||||
// 0.0.0.0/8 (包括未指定地址)
|
|
||||||
if octets[0] == 0 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFC1918 私有地址范围
|
|
||||||
// 10.0.0.0/8
|
|
||||||
if octets[0] == 10 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
|
|
||||||
if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 192.168.0.0/16
|
|
||||||
if octets[0] == 192 && octets[1] == 168 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他特殊地址
|
|
||||||
// 169.254.0.0/16 (链路本地地址)
|
|
||||||
if octets[0] == 169 && octets[1] == 254 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 127.0.0.0/8 (环回地址)
|
|
||||||
if octets[0] == 127 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
std::net::IpAddr::V6(ipv6) => {
|
|
||||||
// IPv6 私有地址检查 - 使用标准库方法
|
|
||||||
|
|
||||||
// ::1 (环回地址)
|
|
||||||
if ipv6.is_loopback() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 唯一本地地址 (fc00::/7)
|
|
||||||
// Rust 1.70+ 可以使用 ipv6.is_unique_local()
|
|
||||||
// 但为了兼容性,我们手动检查
|
|
||||||
let first_segment = ipv6.segments()[0];
|
|
||||||
if (first_segment & 0xfe00) == 0xfc00 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 链路本地地址 (fe80::/10)
|
|
||||||
if (first_segment & 0xffc0) == 0xfe80 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未指定地址 ::
|
|
||||||
if ipv6.is_unspecified() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否为可疑的主机名(只检查明显不安全的模式)
|
|
||||||
fn is_suspicious_hostname(hostname: &str) -> bool {
|
|
||||||
// 空主机名
|
|
||||||
if hostname.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查明显的主机名格式问题
|
|
||||||
if hostname.contains("..") || hostname.starts_with(".") || hostname.ends_with(".") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为纯IP地址但没有合理格式(过于宽松的检查在这里可能不够,但主要依赖后续的同源检查)
|
|
||||||
if hostname.parse::<std::net::IpAddr>().is_ok() {
|
|
||||||
// IP地址格式的,在这里不直接拒绝,让同源检查来处理
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否包含明显不当的字符
|
|
||||||
let suspicious_chars = ['<', '>', '"', '\'', '\n', '\r', '\t', '\0'];
|
|
||||||
if hostname.chars().any(|c| suspicious_chars.contains(&c)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断 URL 是否指向本机(localhost / loopback)
|
|
||||||
fn is_loopback_host(url: &Url) -> bool {
|
|
||||||
match url.host() {
|
|
||||||
Some(Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
|
|
||||||
Some(Host::Ipv4(ip)) => ip.is_loopback(),
|
|
||||||
Some(Host::Ipv6(ip)) => ip.is_loopback(),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_private_ip_validation() {
|
|
||||||
// 测试IPv4私网地址
|
|
||||||
|
|
||||||
// RFC1918私网地址 - 应该返回true
|
|
||||||
assert!(is_private_ip("10.0.0.1"));
|
|
||||||
assert!(is_private_ip("10.255.255.254"));
|
|
||||||
assert!(is_private_ip("172.16.0.1"));
|
|
||||||
assert!(is_private_ip("172.31.255.255"));
|
|
||||||
assert!(is_private_ip("192.168.0.1"));
|
|
||||||
assert!(is_private_ip("192.168.255.255"));
|
|
||||||
|
|
||||||
// 链路本地地址 - 应该返回true
|
|
||||||
assert!(is_private_ip("169.254.0.1"));
|
|
||||||
assert!(is_private_ip("169.254.255.255"));
|
|
||||||
|
|
||||||
// 环回地址 - 应该返回true
|
|
||||||
assert!(is_private_ip("127.0.0.1"));
|
|
||||||
assert!(is_private_ip("localhost"));
|
|
||||||
|
|
||||||
// 公网172.x.x.x地址 - 应该返回false(这是修复的重点)
|
|
||||||
assert!(!is_private_ip("172.0.0.1"));
|
|
||||||
assert!(!is_private_ip("172.15.255.255"));
|
|
||||||
assert!(!is_private_ip("172.32.0.1"));
|
|
||||||
assert!(!is_private_ip("172.64.0.1"));
|
|
||||||
assert!(!is_private_ip("172.67.0.1")); // Cloudflare CDN
|
|
||||||
assert!(!is_private_ip("172.68.0.1"));
|
|
||||||
assert!(!is_private_ip("172.100.50.25"));
|
|
||||||
assert!(!is_private_ip("172.255.255.255"));
|
|
||||||
|
|
||||||
// 其他公网地址 - 应该返回false
|
|
||||||
assert!(!is_private_ip("8.8.8.8")); // Google DNS
|
|
||||||
assert!(!is_private_ip("1.1.1.1")); // Cloudflare DNS
|
|
||||||
assert!(!is_private_ip("208.67.222.222")); // OpenDNS
|
|
||||||
assert!(!is_private_ip("180.76.76.76")); // Baidu DNS
|
|
||||||
|
|
||||||
// 域名 - 应该返回false
|
|
||||||
assert!(!is_private_ip("api.example.com"));
|
|
||||||
assert!(!is_private_ip("www.google.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ipv6_private_validation() {
|
|
||||||
// IPv6私网地址
|
|
||||||
assert!(is_private_ip("::1")); // 环回地址
|
|
||||||
assert!(is_private_ip("fc00::1")); // 唯一本地地址
|
|
||||||
assert!(is_private_ip("fd00::1")); // 唯一本地地址
|
|
||||||
assert!(is_private_ip("fe80::1")); // 链路本地地址
|
|
||||||
assert!(is_private_ip("::")); // 未指定地址
|
|
||||||
|
|
||||||
// IPv6公网地址 - 应该返回false(修复的重点)
|
|
||||||
assert!(!is_private_ip("2001:4860:4860::8888")); // Google DNS IPv6
|
|
||||||
assert!(!is_private_ip("2606:4700:4700::1111")); // Cloudflare DNS IPv6
|
|
||||||
assert!(!is_private_ip("2404:6800:4001:c01::67")); // Google DNS IPv6 (其他格式)
|
|
||||||
assert!(!is_private_ip("2001:db8::1")); // 文档地址(非私网)
|
|
||||||
|
|
||||||
// 测试包含 ::1 子串但不是环回地址的公网地址
|
|
||||||
assert!(!is_private_ip("2001:db8::1abc")); // 包含 ::1abc 但不是环回
|
|
||||||
assert!(!is_private_ip("2606:4700::1")); // 包含 ::1 但不是环回
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_hostname_bypass_prevention() {
|
|
||||||
// 看起来像本地,但实际是域名
|
|
||||||
assert!(!is_private_ip("127.0.0.1.evil.com"));
|
|
||||||
assert!(!is_private_ip("localhost.evil.com"));
|
|
||||||
|
|
||||||
// 0.0.0.0 应该被视为本地/阻断
|
|
||||||
assert!(is_private_ip("0.0.0.0"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_https_bypass_prevention() {
|
|
||||||
// 非本地域名的 HTTP 应该被拒绝
|
|
||||||
let result = validate_base_url("http://127.0.0.1.evil.com/api");
|
|
||||||
assert!(result.is_err(), "Should reject HTTP for non-localhost domains");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_edge_cases() {
|
|
||||||
// 边界情况测试
|
|
||||||
assert!(is_private_ip("172.16.0.0")); // RFC1918起始
|
|
||||||
assert!(is_private_ip("172.31.255.255")); // RFC1918结束
|
|
||||||
assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始
|
|
||||||
assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束
|
|
||||||
assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始
|
|
||||||
assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束
|
|
||||||
|
|
||||||
// 紧邻RFC1918的公网地址 - 应该返回false
|
|
||||||
assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个
|
|
||||||
assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ip_addr_parsing() {
|
|
||||||
// 测试IP地址解析功能
|
|
||||||
let ipv4_private = "10.0.0.1".parse::<std::net::IpAddr>().unwrap();
|
|
||||||
assert!(is_private_ip_addr(ipv4_private));
|
|
||||||
|
|
||||||
let ipv4_public = "172.67.0.1".parse::<std::net::IpAddr>().unwrap();
|
|
||||||
assert!(!is_private_ip_addr(ipv4_public));
|
|
||||||
|
|
||||||
let ipv6_private = "fc00::1".parse::<std::net::IpAddr>().unwrap();
|
|
||||||
assert!(is_private_ip_addr(ipv6_private));
|
|
||||||
|
|
||||||
let ipv6_public = "2001:4860:4860::8888".parse::<std::net::IpAddr>().unwrap();
|
|
||||||
assert!(!is_private_ip_addr(ipv6_public));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_port_comparison() {
|
|
||||||
// 测试端口比较逻辑是否正确处理默认端口和显式端口
|
|
||||||
|
|
||||||
// 测试用例:(base_url, request_url, should_match)
|
|
||||||
let test_cases = vec![
|
|
||||||
// HTTPS默认端口测试
|
|
||||||
("https://api.example.com", "https://api.example.com/v1/test", true),
|
|
||||||
("https://api.example.com", "https://api.example.com:443/v1/test", true),
|
|
||||||
("https://api.example.com:443", "https://api.example.com/v1/test", true),
|
|
||||||
("https://api.example.com:443", "https://api.example.com:443/v1/test", true),
|
|
||||||
|
|
||||||
// 端口不匹配测试
|
|
||||||
("https://api.example.com", "https://api.example.com:8443/v1/test", false),
|
|
||||||
("https://api.example.com:443", "https://api.example.com:8443/v1/test", false),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (base_url, request_url, should_match) in test_cases {
|
|
||||||
let result = validate_request_url(request_url, base_url);
|
|
||||||
|
|
||||||
if should_match {
|
|
||||||
assert!(result.is_ok(),
|
|
||||||
"应该匹配的URL被拒绝: base_url={}, request_url={}, error={}",
|
|
||||||
base_url, request_url, result.unwrap_err());
|
|
||||||
} else {
|
|
||||||
assert!(result.is_err(),
|
|
||||||
"应该不匹配的URL被允许: base_url={}, request_url={}",
|
|
||||||
base_url, request_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ interface ProviderIconProps {
|
|||||||
export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
||||||
icon,
|
icon,
|
||||||
name,
|
name,
|
||||||
color,
|
|
||||||
size = 32,
|
size = 32,
|
||||||
className,
|
className,
|
||||||
showFallback = true,
|
showFallback = true,
|
||||||
@@ -47,7 +46,7 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
|||||||
"inline-flex items-center justify-center flex-shrink-0",
|
"inline-flex items-center justify-center flex-shrink-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ ...sizeStyle, color }}
|
style={sizeStyle}
|
||||||
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center"
|
className="w-20 h-20 p-3 rounded-xl border-2 border-gray-300 dark:border-gray-600 hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-gray-800/50 flex items-center justify-center"
|
||||||
title={currentIcon ? "点击更换图标" : "点击选择图标"}
|
title={currentIcon ? "点击更换图标" : "点击选择图标"}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
onClick={() => handleSelect(entry.url)}
|
onClick={() => handleSelect(entry.url)}
|
||||||
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition text-foreground ${
|
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary/70 bg-primary/5 shadow-sm"
|
? "border-primary/70 bg-primary/5 shadow-sm"
|
||||||
: "border-border-default bg-background hover:bg-muted"
|
: "border-border-default bg-background hover:bg-muted"
|
||||||
@@ -577,7 +577,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
|
|
||||||
{/* 内容 */}
|
{/* 内容 */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm text-foreground">
|
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
|
||||||
{entry.url}
|
{entry.url}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,17 +19,11 @@ import { RefreshCw, Search } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SkillCard } from "./SkillCard";
|
import { SkillCard } from "./SkillCard";
|
||||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||||
import {
|
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
|
||||||
skillsApi,
|
|
||||||
type Skill,
|
|
||||||
type SkillRepo,
|
|
||||||
type AppType,
|
|
||||||
} from "@/lib/api/skills";
|
|
||||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||||
|
|
||||||
interface SkillsPageProps {
|
interface SkillsPageProps {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
initialApp?: AppType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsPageHandle {
|
export interface SkillsPageHandle {
|
||||||
@@ -38,7 +32,7 @@ export interface SkillsPageHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||||
({ onClose: _onClose, initialApp = "claude" }, ref) => {
|
({ onClose: _onClose }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [skills, setSkills] = useState<Skill[]>([]);
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||||
@@ -48,13 +42,11 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
const [filterStatus, setFilterStatus] = useState<
|
const [filterStatus, setFilterStatus] = useState<
|
||||||
"all" | "installed" | "uninstalled"
|
"all" | "installed" | "uninstalled"
|
||||||
>("all");
|
>("all");
|
||||||
// 使用 initialApp,不允许切换
|
|
||||||
const selectedApp = initialApp;
|
|
||||||
|
|
||||||
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await skillsApi.getAll(selectedApp);
|
const data = await skillsApi.getAll();
|
||||||
setSkills(data);
|
setSkills(data);
|
||||||
if (afterLoad) {
|
if (afterLoad) {
|
||||||
afterLoad(data);
|
afterLoad(data);
|
||||||
@@ -92,7 +84,6 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([loadSkills(), loadRepos()]);
|
Promise.all([loadSkills(), loadRepos()]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
@@ -102,7 +93,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
|
|
||||||
const handleInstall = async (directory: string) => {
|
const handleInstall = async (directory: string) => {
|
||||||
try {
|
try {
|
||||||
await skillsApi.install(directory, selectedApp);
|
await skillsApi.install(directory);
|
||||||
toast.success(t("skills.installSuccess", { name: directory }));
|
toast.success(t("skills.installSuccess", { name: directory }));
|
||||||
await loadSkills();
|
await loadSkills();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -131,7 +122,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
|
|
||||||
const handleUninstall = async (directory: string) => {
|
const handleUninstall = async (directory: string) => {
|
||||||
try {
|
try {
|
||||||
await skillsApi.uninstall(directory, selectedApp);
|
await skillsApi.uninstall(directory);
|
||||||
toast.success(t("skills.uninstallSuccess", { name: directory }));
|
toast.success(t("skills.uninstallSuccess", { name: directory }));
|
||||||
await loadSkills();
|
await loadSkills();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-border-default bg-background text-foreground px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border border-border-default bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -221,36 +221,30 @@ export function RequestLogTable() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="whitespace-nowrap">
|
<TableHead>{t("usage.time", "时间")}</TableHead>
|
||||||
{t("usage.time", "时间")}
|
<TableHead>{t("usage.provider", "供应商")}</TableHead>
|
||||||
</TableHead>
|
<TableHead className="min-w-[280px]">
|
||||||
<TableHead className="whitespace-nowrap">
|
|
||||||
{t("usage.provider", "供应商")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[280px] whitespace-nowrap">
|
|
||||||
{t("usage.billingModel", "计费模型")}
|
{t("usage.billingModel", "计费模型")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right">
|
||||||
{t("usage.inputTokens", "输入")}
|
{t("usage.inputTokens", "输入")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right">
|
||||||
{t("usage.outputTokens", "输出")}
|
{t("usage.outputTokens", "输出")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
<TableHead className="text-right min-w-[90px]">
|
||||||
{t("usage.cacheReadTokens", "缓存读取")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
|
||||||
{t("usage.cacheCreationTokens", "缓存写入")}
|
{t("usage.cacheCreationTokens", "缓存写入")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right min-w-[90px]">
|
||||||
|
{t("usage.cacheReadTokens", "缓存读取")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
{t("usage.totalCost", "成本")}
|
{t("usage.totalCost", "成本")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
|
<TableHead className="text-center min-w-[140px]">
|
||||||
{t("usage.timingInfo", "用时/首字")}
|
{t("usage.timingInfo", "用时/首字")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">
|
<TableHead>{t("usage.status", "状态")}</TableHead>
|
||||||
{t("usage.status", "状态")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -286,10 +280,10 @@ export function RequestLogTable() {
|
|||||||
{log.outputTokens.toLocaleString()}
|
{log.outputTokens.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{log.cacheReadTokens.toLocaleString()}
|
{log.cacheCreationTokens.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{log.cacheCreationTokens.toLocaleString()}
|
{log.cacheReadTokens.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
${parseFloat(log.totalCostUsd).toFixed(6)}
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
|||||||
displayName: "OpenAI",
|
displayName: "OpenAI",
|
||||||
category: "ai-provider",
|
category: "ai-provider",
|
||||||
keywords: ["gpt", "chatgpt"],
|
keywords: ["gpt", "chatgpt"],
|
||||||
defaultColor: "currentColor",
|
defaultColor: "#00A67E",
|
||||||
},
|
},
|
||||||
packycode: {
|
packycode: {
|
||||||
name: "packycode",
|
name: "packycode",
|
||||||
|
|||||||
+6
-20
@@ -19,31 +19,17 @@ export interface SkillRepo {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppType = "claude" | "codex" | "gemini";
|
|
||||||
|
|
||||||
export const skillsApi = {
|
export const skillsApi = {
|
||||||
async getAll(app: AppType = "claude"): Promise<Skill[]> {
|
async getAll(): Promise<Skill[]> {
|
||||||
if (app === "claude") {
|
return await invoke("get_skills");
|
||||||
return await invoke("get_skills");
|
|
||||||
}
|
|
||||||
return await invoke("get_skills_for_app", { app });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
|
async install(directory: string): Promise<boolean> {
|
||||||
if (app === "claude") {
|
return await invoke("install_skill", { directory });
|
||||||
return await invoke("install_skill", { directory });
|
|
||||||
}
|
|
||||||
return await invoke("install_skill_for_app", { app, directory });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async uninstall(
|
async uninstall(directory: string): Promise<boolean> {
|
||||||
directory: string,
|
return await invoke("uninstall_skill", { directory });
|
||||||
app: AppType = "claude",
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (app === "claude") {
|
|
||||||
return await invoke("uninstall_skill", { directory });
|
|
||||||
}
|
|
||||||
return await invoke("uninstall_skill_for_app", { app, directory });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getRepos(): Promise<SkillRepo[]> {
|
async getRepos(): Promise<SkillRepo[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user