mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d49481a259 | |||
| 92a07bdb2b | |||
| 3cdce2eced | |||
| 8876d67807 | |||
| 493b154a9d | |||
| 56b40bdad2 | |||
| 41267135f5 |
+131
-53
@@ -69,8 +69,6 @@ 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();
|
||||||
|
|
||||||
@@ -81,47 +79,24 @@ 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") {
|
// 先尝试直接执行
|
||||||
Command::new("cmd")
|
let direct_result = try_get_version(tool);
|
||||||
.args(["/C", &format!("{tool} --version")])
|
|
||||||
.output()
|
|
||||||
} else {
|
|
||||||
Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(format!("{tool} --version"))
|
|
||||||
.output()
|
|
||||||
};
|
|
||||||
|
|
||||||
match output {
|
if direct_result.0.is_some() {
|
||||||
Ok(out) => {
|
direct_result
|
||||||
if out.status.success() {
|
} else {
|
||||||
(
|
// 扫描常见的 npm 全局安装路径
|
||||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string()),
|
scan_cli_version(tool)
|
||||||
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_github_latest_release(&client, "openai/openai-python").await,
|
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await, // 修正:使用 npm 官方包 @google/gemini-cli
|
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,24 +111,6 @@ 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}");
|
||||||
@@ -171,3 +128,124 @@ 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,3 +1,4 @@
|
|||||||
|
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};
|
||||||
@@ -8,15 +9,46 @@ 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())?;
|
||||||
@@ -26,16 +58,19 @@ pub async fn get_skills(
|
|||||||
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 && !existing_states.contains_key(&skill.directory) {
|
if skill.installed {
|
||||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
let key = get_skill_key(&app_type, &skill.directory);
|
||||||
if let Err(e) = app_state.db.update_skill_state(
|
if !existing_states.contains_key(&key) {
|
||||||
&skill.directory,
|
// 本地有该 skill,但数据库中没有记录,自动添加
|
||||||
&SkillState {
|
if let Err(e) = app_state.db.update_skill_state(
|
||||||
installed: true,
|
&key,
|
||||||
installed_at: Utc::now(),
|
&SkillState {
|
||||||
},
|
installed: true,
|
||||||
) {
|
installed_at: Utc::now(),
|
||||||
log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
|
},
|
||||||
|
) {
|
||||||
|
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,11 +84,23 @@ 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())?;
|
||||||
@@ -93,16 +140,16 @@ pub async fn install_skill(
|
|||||||
};
|
};
|
||||||
|
|
||||||
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(
|
||||||
&directory,
|
&key,
|
||||||
&SkillState {
|
&SkillState {
|
||||||
installed: true,
|
installed: true,
|
||||||
installed_at: Utc::now(),
|
installed_at: Utc::now(),
|
||||||
@@ -119,16 +166,29 @@ 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(
|
||||||
&directory,
|
&key,
|
||||||
&SkillState {
|
&SkillState {
|
||||||
installed: false,
|
installed: false,
|
||||||
installed_at: Utc::now(),
|
installed_at: Utc::now(),
|
||||||
|
|||||||
@@ -13,18 +13,22 @@ 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 key, installed, installed_at FROM skills ORDER BY key ASC")
|
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
let skill_iter = stmt
|
let skill_iter = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
let key: String = row.get(0)?;
|
let directory: String = row.get(0)?;
|
||||||
let installed: bool = row.get(1)?;
|
let app_type: String = row.get(1)?;
|
||||||
let installed_at_ts: i64 = row.get(2)?;
|
let installed: bool = 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 {
|
||||||
@@ -44,11 +48,21 @@ 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 (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![key, state.installed, state.installed_at.timestamp()],
|
params![directory, app_type, 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,9 +96,11 @@ impl Database {
|
|||||||
// 5. Skills 表
|
// 5. Skills 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS skills (
|
"CREATE TABLE IF NOT EXISTS skills (
|
||||||
key TEXT PRIMARY KEY,
|
directory TEXT NOT NULL,
|
||||||
|
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)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -391,7 +393,9 @@ impl Database {
|
|||||||
Self::set_user_version(conn, 1)?;
|
Self::set_user_version(conn, 1)?;
|
||||||
}
|
}
|
||||||
1 => {
|
1 => {
|
||||||
log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)");
|
log::info!(
|
||||||
|
"迁移数据库从 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)?;
|
||||||
}
|
}
|
||||||
@@ -480,7 +484,7 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v1 -> v2 迁移:添加使用统计表和完整字段
|
/// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表
|
||||||
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(
|
||||||
@@ -576,6 +580,82 @@ 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,8 +635,11 @@ 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::new_in_memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
let router = ProviderRouter::new(db);
|
let router = ProviderRouter::new(db);
|
||||||
|
|
||||||
// 测试创建熔断器
|
// 测试创建熔断器
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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;
|
||||||
|
|
||||||
/// 技能对象
|
/// 技能对象
|
||||||
@@ -106,11 +107,16 @@ 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> {
|
||||||
let install_dir = Self::get_install_dir()?;
|
Self::new_for_app(AppType::Claude)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)?;
|
||||||
@@ -122,16 +128,38 @@ 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() -> Result<PathBuf> {
|
fn get_install_dir_for_app(app_type: &AppType) -> 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+464
-18
@@ -3,6 +3,7 @@ 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;
|
||||||
|
|
||||||
@@ -15,20 +16,14 @@ 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 mut replaced = script_code
|
let script_with_vars =
|
||||||
.replace("{{apiKey}}", api_key)
|
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
||||||
.replace("{{baseUrl}}", base_url);
|
|
||||||
|
|
||||||
// 替换 accessToken 和 userId
|
// 2. 验证 base_url 的安全性
|
||||||
if let Some(token) = access_token {
|
validate_base_url(base_url)?;
|
||||||
replaced = replaced.replace("{{accessToken}}", token);
|
|
||||||
}
|
|
||||||
if let Some(uid) = user_id {
|
|
||||||
replaced = replaced.replace("{{userId}}", uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
// 3. 在独立作用域中提取 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(
|
||||||
@@ -47,7 +42,7 @@ pub async fn execute_usage_script(
|
|||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 执行用户代码,获取配置对象
|
// 执行用户代码,获取配置对象
|
||||||
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_parse_failed",
|
"usage_script.config_parse_failed",
|
||||||
format!("解析配置失败: {e}"),
|
format!("解析配置失败: {e}"),
|
||||||
@@ -94,7 +89,7 @@ pub async fn execute_usage_script(
|
|||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
// 3. 解析 request 配置
|
// 4. 解析 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",
|
||||||
@@ -103,10 +98,13 @@ pub async fn execute_usage_script(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 4. 发送 HTTP 请求
|
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
||||||
|
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?;
|
||||||
|
|
||||||
// 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
// 7. 在独立作用域中执行 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(
|
||||||
@@ -125,7 +123,7 @@ pub async fn execute_usage_script(
|
|||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 重新 eval 获取配置对象
|
// 重新 eval 获取配置对象
|
||||||
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_reparse_failed",
|
"usage_script.config_reparse_failed",
|
||||||
format!("重新解析配置失败: {e}"),
|
format!("重新解析配置失败: {e}"),
|
||||||
@@ -198,7 +196,7 @@ pub async fn execute_usage_script(
|
|||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
// 6. 验证返回值格式
|
// 8. 验证返回值格式
|
||||||
validate_result(&result)?;
|
validate_result(&result)?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -394,3 +392,451 @@ 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,6 +14,7 @@ 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,
|
||||||
@@ -46,7 +47,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}
|
style={{ ...sizeStyle, color }}
|
||||||
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-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"
|
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"
|
||||||
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 ${
|
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition text-foreground ${
|
||||||
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-gray-900 dark:text-gray-100">
|
<div className="truncate text-sm text-foreground">
|
||||||
{entry.url}
|
{entry.url}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ 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 { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
|
import {
|
||||||
|
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 {
|
||||||
@@ -32,7 +38,7 @@ export interface SkillsPageHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||||
({ onClose: _onClose }, ref) => {
|
({ onClose: _onClose, initialApp = "claude" }, 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[]>([]);
|
||||||
@@ -42,11 +48,13 @@ 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();
|
const data = await skillsApi.getAll(selectedApp);
|
||||||
setSkills(data);
|
setSkills(data);
|
||||||
if (afterLoad) {
|
if (afterLoad) {
|
||||||
afterLoad(data);
|
afterLoad(data);
|
||||||
@@ -84,6 +92,7 @@ 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, () => ({
|
||||||
@@ -93,7 +102,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
|
|
||||||
const handleInstall = async (directory: string) => {
|
const handleInstall = async (directory: string) => {
|
||||||
try {
|
try {
|
||||||
await skillsApi.install(directory);
|
await skillsApi.install(directory, selectedApp);
|
||||||
toast.success(t("skills.installSuccess", { name: directory }));
|
toast.success(t("skills.installSuccess", { name: directory }));
|
||||||
await loadSkills();
|
await loadSkills();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -122,7 +131,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
|
|
||||||
const handleUninstall = async (directory: string) => {
|
const handleUninstall = async (directory: string) => {
|
||||||
try {
|
try {
|
||||||
await skillsApi.uninstall(directory);
|
await skillsApi.uninstall(directory, selectedApp);
|
||||||
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 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 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",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -221,30 +221,36 @@ export function RequestLogTable() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("usage.time", "时间")}</TableHead>
|
<TableHead className="whitespace-nowrap">
|
||||||
<TableHead>{t("usage.provider", "供应商")}</TableHead>
|
{t("usage.time", "时间")}
|
||||||
<TableHead className="min-w-[280px]">
|
</TableHead>
|
||||||
|
<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">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("usage.inputTokens", "输入")}
|
{t("usage.inputTokens", "输入")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("usage.outputTokens", "输出")}
|
{t("usage.outputTokens", "输出")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right min-w-[90px]">
|
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||||
{t("usage.cacheCreationTokens", "缓存写入")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right min-w-[90px]">
|
|
||||||
{t("usage.cacheReadTokens", "缓存读取")}
|
{t("usage.cacheReadTokens", "缓存读取")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||||
|
{t("usage.cacheCreationTokens", "缓存写入")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("usage.totalCost", "成本")}
|
{t("usage.totalCost", "成本")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center min-w-[140px]">
|
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
|
||||||
{t("usage.timingInfo", "用时/首字")}
|
{t("usage.timingInfo", "用时/首字")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>{t("usage.status", "状态")}</TableHead>
|
<TableHead className="whitespace-nowrap">
|
||||||
|
{t("usage.status", "状态")}
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -280,10 +286,10 @@ export function RequestLogTable() {
|
|||||||
{log.outputTokens.toLocaleString()}
|
{log.outputTokens.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">
|
||||||
{log.cacheReadTokens.toLocaleString()}
|
{log.cacheCreationTokens.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: "#00A67E",
|
defaultColor: "currentColor",
|
||||||
},
|
},
|
||||||
packycode: {
|
packycode: {
|
||||||
name: "packycode",
|
name: "packycode",
|
||||||
|
|||||||
+20
-6
@@ -19,17 +19,31 @@ export interface SkillRepo {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppType = "claude" | "codex" | "gemini";
|
||||||
|
|
||||||
export const skillsApi = {
|
export const skillsApi = {
|
||||||
async getAll(): Promise<Skill[]> {
|
async getAll(app: AppType = "claude"): Promise<Skill[]> {
|
||||||
return await invoke("get_skills");
|
if (app === "claude") {
|
||||||
|
return await invoke("get_skills");
|
||||||
|
}
|
||||||
|
return await invoke("get_skills_for_app", { app });
|
||||||
},
|
},
|
||||||
|
|
||||||
async install(directory: string): Promise<boolean> {
|
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
|
||||||
return await invoke("install_skill", { directory });
|
if (app === "claude") {
|
||||||
|
return await invoke("install_skill", { directory });
|
||||||
|
}
|
||||||
|
return await invoke("install_skill_for_app", { app, directory });
|
||||||
},
|
},
|
||||||
|
|
||||||
async uninstall(directory: string): Promise<boolean> {
|
async uninstall(
|
||||||
return await invoke("uninstall_skill", { directory });
|
directory: string,
|
||||||
|
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