mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-16 09:39:29 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80c61a7fc7 |
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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