mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73aa95263c | |||
| e1db104009 | |||
| af9785f566 | |||
| a20fb1afcf | |||
| 760d564817 | |||
| 1121347a45 | |||
| a64e588cf8 | |||
| ab08b906ad | |||
| 26753f3d66 | |||
| d783cbe745 | |||
| d304346514 | |||
| 1efa41082c | |||
| 72412e7abd | |||
| b496e250dd | |||
| 087622bf6d | |||
| 1e95a3f16b | |||
| bfcdde5011 | |||
| 39964b5733 | |||
| 2298119177 | |||
| d356f1e208 | |||
| 434ffd559d | |||
| 38e8949152 | |||
| 96ec6c561e | |||
| 6423e2e8ab | |||
| 05973bf959 | |||
| c0122d717c | |||
| d12347d837 | |||
| fef4c239c4 | |||
| a35d112cd4 | |||
| 665d34609d | |||
| 0eec47fd64 | |||
| e9cb7098a5 | |||
| 247973b37e | |||
| 94b83bfa3a | |||
| c7ef4365b8 | |||
| 4c94e70f97 |
@@ -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)
|
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -371,9 +369,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)?;
|
||||||
}
|
}
|
||||||
@@ -462,7 +458,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(
|
||||||
@@ -558,82 +554,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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,20 +316,9 @@ impl SkillService {
|
|||||||
let directory = &local_skill.directory;
|
let directory = &local_skill.directory;
|
||||||
|
|
||||||
// 更新已安装状态(匹配远程技能)
|
// 更新已安装状态(匹配远程技能)
|
||||||
// 使用目录最后一段进行比较,因为安装时只使用最后一段作为目录名
|
|
||||||
let mut found = false;
|
let mut found = false;
|
||||||
let local_install_name = Path::new(directory)
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| directory.clone());
|
|
||||||
|
|
||||||
for skill in skills.iter_mut() {
|
for skill in skills.iter_mut() {
|
||||||
let remote_install_name = Path::new(&skill.directory)
|
if skill.directory.eq_ignore_ascii_case(directory) {
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| skill.directory.clone());
|
|
||||||
|
|
||||||
if remote_install_name.eq_ignore_ascii_case(&local_install_name) {
|
|
||||||
skill.installed = true;
|
skill.installed = true;
|
||||||
found = true;
|
found = true;
|
||||||
break;
|
break;
|
||||||
@@ -556,14 +517,7 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
|
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
|
||||||
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
|
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
|
||||||
// 使用技能目录的最后一段作为安装目录名,避免嵌套路径问题
|
let dest = self.install_dir.join(&directory);
|
||||||
// 例如: "skills/codex" -> "codex"
|
|
||||||
let install_name = Path::new(&directory)
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| directory.clone());
|
|
||||||
|
|
||||||
let dest = self.install_dir.join(&install_name);
|
|
||||||
|
|
||||||
// 若目标目录已存在,则视为已安装,避免重复下载
|
// 若目标目录已存在,则视为已安装,避免重复下载
|
||||||
if dest.exists() {
|
if dest.exists() {
|
||||||
@@ -635,13 +589,7 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
|
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
|
||||||
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
|
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
|
||||||
// 使用技能目录的最后一段作为安装目录名,与 install_skill 保持一致
|
let dest = self.install_dir.join(&directory);
|
||||||
let install_name = Path::new(&directory)
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| directory.clone());
|
|
||||||
|
|
||||||
let dest = self.install_dir.join(&install_name);
|
|
||||||
|
|
||||||
if dest.exists() {
|
if dest.exists() {
|
||||||
fs::remove_dir_all(&dest)?;
|
fs::remove_dir_all(&dest)?;
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
|
||||||
"app": {
|
|
||||||
"windows": [
|
|
||||||
{
|
|
||||||
"label": "main",
|
|
||||||
"titleBarStyle": "Visible"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-14
@@ -24,6 +24,7 @@ import {
|
|||||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
import { ProviderList } from "@/components/providers/ProviderList";
|
import { ProviderList } from "@/components/providers/ProviderList";
|
||||||
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
||||||
@@ -64,8 +65,7 @@ function App() {
|
|||||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
||||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
const currentProviderId = data?.currentProviderId ?? "";
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
// Skills 功能仅支持 Claude 和 Codex
|
const isClaudeApp = activeApp === "claude";
|
||||||
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
|
|
||||||
|
|
||||||
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
||||||
const {
|
const {
|
||||||
@@ -291,7 +291,6 @@ function App() {
|
|||||||
<SkillsPage
|
<SkillsPage
|
||||||
ref={skillsPageRef}
|
ref={skillsPageRef}
|
||||||
onClose={() => setCurrentView("providers")}
|
onClose={() => setCurrentView("providers")}
|
||||||
initialApp={activeApp}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "mcp":
|
case "mcp":
|
||||||
@@ -479,17 +478,21 @@ function App() {
|
|||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||||
|
|
||||||
<div className="glass p-1 rounded-xl flex items-center gap-1">
|
<div className="glass p-1 rounded-xl flex items-center gap-1">
|
||||||
{hasSkillsSupport && (
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setCurrentView("skills")}
|
||||||
onClick={() => setCurrentView("skills")}
|
className={cn(
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||||
title={t("skills.manage")}
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||||
>
|
isClaudeApp
|
||||||
<Wrench className="h-4 w-4" />
|
? "opacity-100 w-8 scale-100 px-2"
|
||||||
</Button>
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||||
)}
|
)}
|
||||||
|
title={t("skills.manage")}
|
||||||
|
>
|
||||||
|
<Wrench className="h-4 w-4 flex-shrink-0" />
|
||||||
|
</Button>
|
||||||
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
||||||
{/* {isClaudeApp && (
|
{/* {isClaudeApp && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -193,36 +193,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>
|
||||||
@@ -258,10 +252,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)}
|
||||||
|
|||||||
+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[]> {
|
||||||
|
|||||||
+12
-16
@@ -36,10 +36,9 @@ export const useAddProviderMutation = (appId: AppId) => {
|
|||||||
toast.success(
|
toast.success(
|
||||||
t("notifications.providerAdded", {
|
t("notifications.providerAdded", {
|
||||||
defaultValue: "供应商已添加",
|
defaultValue: "供应商已添加",
|
||||||
}),
|
}), {
|
||||||
{
|
closeButton: true
|
||||||
closeButton: true,
|
}
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
@@ -67,10 +66,9 @@ export const useUpdateProviderMutation = (appId: AppId) => {
|
|||||||
toast.success(
|
toast.success(
|
||||||
t("notifications.updateSuccess", {
|
t("notifications.updateSuccess", {
|
||||||
defaultValue: "供应商更新成功",
|
defaultValue: "供应商更新成功",
|
||||||
}),
|
}), {
|
||||||
{
|
closeButton: true
|
||||||
closeButton: true,
|
}
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
@@ -108,10 +106,9 @@ export const useDeleteProviderMutation = (appId: AppId) => {
|
|||||||
toast.success(
|
toast.success(
|
||||||
t("notifications.deleteSuccess", {
|
t("notifications.deleteSuccess", {
|
||||||
defaultValue: "供应商已删除",
|
defaultValue: "供应商已删除",
|
||||||
}),
|
}), {
|
||||||
{
|
closeButton: true
|
||||||
closeButton: true,
|
}
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
@@ -150,10 +147,9 @@ export const useSwitchProviderMutation = (appId: AppId) => {
|
|||||||
t("notifications.switchSuccess", {
|
t("notifications.switchSuccess", {
|
||||||
defaultValue: "切换供应商成功",
|
defaultValue: "切换供应商成功",
|
||||||
appName: t(`apps.${appId}`, { defaultValue: appId }),
|
appName: t(`apps.${appId}`, { defaultValue: appId }),
|
||||||
}),
|
}), {
|
||||||
{
|
closeButton: true
|
||||||
closeButton: true,
|
}
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user