mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-27 05:01:06 +08:00
Compare commits
2 Commits
feat/updat
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb36ead45 | ||
|
|
663acf49e8 |
@@ -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)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -369,7 +371,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)?;
|
||||||
}
|
}
|
||||||
@@ -458,7 +462,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(
|
||||||
@@ -554,6 +558,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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
src/App.tsx
31
src/App.tsx
@@ -24,7 +24,6 @@ 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";
|
||||||
@@ -65,7 +64,8 @@ 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 ?? "";
|
||||||
const isClaudeApp = activeApp === "claude";
|
// Skills 功能仅支持 Claude 和 Codex
|
||||||
|
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
|
||||||
|
|
||||||
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
||||||
const {
|
const {
|
||||||
@@ -291,6 +291,7 @@ function App() {
|
|||||||
<SkillsPage
|
<SkillsPage
|
||||||
ref={skillsPageRef}
|
ref={skillsPageRef}
|
||||||
onClose={() => setCurrentView("providers")}
|
onClose={() => setCurrentView("providers")}
|
||||||
|
initialApp={activeApp}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "mcp":
|
case "mcp":
|
||||||
@@ -478,21 +479,17 @@ 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">
|
||||||
<Button
|
{hasSkillsSupport && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setCurrentView("skills")}
|
size="sm"
|
||||||
className={cn(
|
onClick={() => setCurrentView("skills")}
|
||||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
title={t("skills.manage")}
|
||||||
isClaudeApp
|
>
|
||||||
? "opacity-100 w-8 scale-100 px-2"
|
<Wrench className="h-4 w-4" />
|
||||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
</Button>
|
||||||
)}
|
)}
|
||||||
title={t("skills.manage")}
|
|
||||||
>
|
|
||||||
<Wrench className="h-4 w-4 flex-shrink-0" />
|
|
||||||
</Button>
|
|
||||||
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
||||||
{/* {isClaudeApp && (
|
{/* {isClaudeApp && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -193,30 +193,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>
|
||||||
@@ -252,10 +258,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)}
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ 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) => {
|
||||||
@@ -66,9 +67,10 @@ 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) => {
|
||||||
@@ -106,9 +108,10 @@ 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) => {
|
||||||
@@ -147,9 +150,10 @@ 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