Merge branch 'main' into refactor/ui

# Conflicts:
#	src-tauri/src/services/skill.rs
#	src/components/skills/SkillsPage.tsx
This commit is contained in:
YoVinchen
2025-11-22 00:04:28 +08:00
7 changed files with 314 additions and 23 deletions

View File

@@ -1,3 +1,4 @@
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::store::AppState;
@@ -45,18 +46,36 @@ pub async fn install_skill(
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| "技能不存在".to_string())?;
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
&[("directory", &directory)],
Some("checkRepoUrl"),
)
})?;
if !skill.installed {
let repo = SkillRepo {
owner: skill
.repo_owner
.clone()
.ok_or_else(|| "缺少仓库信息".to_string())?,
.ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill
.repo_name
.clone()
.ok_or_else(|| "缺少仓库信息".to_string())?,
.ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
branch: skill
.repo_branch
.clone()

View File

@@ -94,3 +94,28 @@ impl From<AppError> for String {
err.to_string()
}
}
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
pub fn format_skill_error(
code: &str,
context: &[(&str, &str)],
suggestion: Option<&str>,
) -> String {
use serde_json::json;
let mut ctx_map = serde_json::Map::new();
for (key, value) in context {
ctx_map.insert(key.to_string(), json!(value));
}
let error_obj = json!({
"code": code,
"context": ctx_map,
"suggestion": suggestion,
});
serde_json::to_string(&error_obj).unwrap_or_else(|_| {
// 如果 JSON 序列化失败,返回简单格式
format!("ERROR:{}", code)
})
}

View File

@@ -7,6 +7,8 @@ use std::fs;
use std::path::{Path, PathBuf};
use tokio::time::timeout;
use crate::error::format_skill_error;
/// 技能对象
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
@@ -133,7 +135,11 @@ impl SkillService {
}
fn get_install_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("无法获取用户主目录")?;
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
Ok(home.join(".claude").join("skills"))
}
}
@@ -173,9 +179,19 @@ impl SkillService {
/// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
let temp_dir = timeout(std::time::Duration::from_secs(15), self.download_repo(repo))
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.await
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let mut skills = Vec::new();
// 确定要扫描的目录路径
@@ -379,7 +395,17 @@ impl SkillService {
// 下载 ZIP
let response = self.http_client.get(url).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("下载失败: {}", response.status()));
let status = response.status().as_u16().to_string();
return Err(anyhow::anyhow!(format_skill_error(
"DOWNLOAD_FAILED",
&[("status", &status)],
match status.as_str() {
"403" => Some("http403"),
"404" => Some("http404"),
"429" => Some("http429"),
_ => Some("checkNetwork"),
},
)));
}
let bytes = response.bytes().await?;
@@ -394,7 +420,11 @@ impl SkillService {
let name = first_file.name();
name.split('/').next().unwrap_or("").to_string()
} else {
return Err(anyhow::anyhow!("空的压缩包"));
return Err(anyhow::anyhow!(format_skill_error(
"EMPTY_ARCHIVE",
&[],
Some("checkRepoUrl"),
)));
};
// 解压所有文件
@@ -441,18 +471,26 @@ impl SkillService {
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
let temp_dir = timeout(
std::time::Duration::from_secs(15),
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
.await
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
// 根据 skills_path 确定源目录路径
let source = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skills_path源路径为: temp_dir/skills_path/directory
temp_dir
.join(skills_path.trim_matches('/'))
.join(&directory)
temp_dir.join(skills_path.trim_matches('/')).join(&directory)
} else {
// 否则源路径为: temp_dir/directory
temp_dir.join(&directory)
@@ -460,7 +498,11 @@ impl SkillService {
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow::anyhow!("技能目录不存在: {}", source.display()));
return Err(anyhow::anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
)));
}
// 删除旧版本