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::SkillState;
use crate::services::{Skill, SkillRepo, SkillService}; use crate::services::{Skill, SkillRepo, SkillService};
use crate::store::AppState; use crate::store::AppState;
@@ -45,18 +46,36 @@ pub async fn install_skill(
let skill = skills let skill = skills
.iter() .iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory)) .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 { if !skill.installed {
let repo = SkillRepo { let repo = SkillRepo {
owner: skill owner: skill
.repo_owner .repo_owner
.clone() .clone()
.ok_or_else(|| "缺少仓库信息".to_string())?, .ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill name: skill
.repo_name .repo_name
.clone() .clone()
.ok_or_else(|| "缺少仓库信息".to_string())?, .ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
branch: skill branch: skill
.repo_branch .repo_branch
.clone() .clone()

View File

@@ -94,3 +94,28 @@ impl From<AppError> for String {
err.to_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 std::path::{Path, PathBuf};
use tokio::time::timeout; use tokio::time::timeout;
use crate::error::format_skill_error;
/// 技能对象 /// 技能对象
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill { pub struct Skill {
@@ -133,7 +135,11 @@ impl SkillService {
} }
fn get_install_dir() -> Result<PathBuf> { 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")) Ok(home.join(".claude").join("skills"))
} }
} }
@@ -173,9 +179,19 @@ impl SkillService {
/// 从仓库获取技能列表 /// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> { 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 .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(); let mut skills = Vec::new();
// 确定要扫描的目录路径 // 确定要扫描的目录路径
@@ -379,7 +395,17 @@ impl SkillService {
// 下载 ZIP // 下载 ZIP
let response = self.http_client.get(url).send().await?; let response = self.http_client.get(url).send().await?;
if !response.status().is_success() { 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?; let bytes = response.bytes().await?;
@@ -394,7 +420,11 @@ impl SkillService {
let name = first_file.name(); let name = first_file.name();
name.split('/').next().unwrap_or("").to_string() name.split('/').next().unwrap_or("").to_string()
} else { } 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( let temp_dir = timeout(
std::time::Duration::from_secs(15), std::time::Duration::from_secs(60),
self.download_repo(&repo), self.download_repo(&repo),
) )
.await .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 确定源目录路径 // 根据 skills_path 确定源目录路径
let source = if let Some(ref skills_path) = repo.skills_path { let source = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skills_path源路径为: temp_dir/skills_path/directory // 如果指定了 skills_path源路径为: temp_dir/skills_path/directory
temp_dir temp_dir.join(skills_path.trim_matches('/')).join(&directory)
.join(skills_path.trim_matches('/'))
.join(&directory)
} else { } else {
// 否则源路径为: temp_dir/directory // 否则源路径为: temp_dir/directory
temp_dir.join(&directory) temp_dir.join(&directory)
@@ -460,7 +498,11 @@ impl SkillService {
if !source.exists() { if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir); 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"),
)));
} }
// 删除旧版本 // 删除旧版本

View File

@@ -6,6 +6,7 @@ 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 } from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps { interface SkillsPageProps {
onClose?: () => void; onClose?: () => void;
@@ -33,10 +34,22 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
afterLoad(data); afterLoad(data);
} }
} catch (error) { } catch (error) {
toast.error(t("skills.loadFailed"), { const errorMessage =
description: error instanceof Error ? error.message : String(error);
error instanceof Error ? error.message : t("common.error"),
// 传入 "skills.loadFailed" 作为标题
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.loadFailed",
);
toast.error(title, {
description,
duration: 8000,
}); });
console.error("Load skills failed:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -66,9 +79,25 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
toast.success(t("skills.installSuccess", { name: directory })); toast.success(t("skills.installSuccess", { name: directory }));
await loadSkills(); await loadSkills();
} catch (error) { } catch (error) {
toast.error(t("skills.installFailed"), { const errorMessage =
description: error instanceof Error ? error.message : String(error);
error instanceof Error ? error.message : t("common.error"),
// 使用错误解析器格式化错误,传入 "skills.installFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.installFailed",
);
toast.error(title, {
description,
duration: 10000, // 延长显示时间让用户看清
});
console.error("Install skill failed:", {
directory,
error,
message: errorMessage,
}); });
} }
}; };
@@ -79,9 +108,25 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
toast.success(t("skills.uninstallSuccess", { name: directory })); toast.success(t("skills.uninstallSuccess", { name: directory }));
await loadSkills(); await loadSkills();
} catch (error) { } catch (error) {
toast.error(t("skills.uninstallFailed"), { const errorMessage =
description: error instanceof Error ? error.message : String(error);
error instanceof Error ? error.message : t("common.error"),
// 使用错误解析器格式化错误,传入 "skills.uninstallFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.uninstallFailed",
);
toast.error(title, {
description,
duration: 10000,
});
console.error("Uninstall skill failed:", {
directory,
error,
message: errorMessage,
}); });
} }
}; };

View File

@@ -689,6 +689,34 @@
"installFailed": "Failed to install", "installFailed": "Failed to install",
"uninstallSuccess": "Skill {{name}} uninstalled", "uninstallSuccess": "Skill {{name}} uninstalled",
"uninstallFailed": "Failed to uninstall", "uninstallFailed": "Failed to uninstall",
"error": {
"skillNotFound": "Skill not found: {{directory}}",
"missingRepoInfo": "Missing repository info (owner or name)",
"downloadTimeout": "Download repository {{owner}}/{{name}} timeout ({{timeout}}s)",
"downloadTimeoutHint": "Please check network connection or retry later",
"skillPathNotFound": "Skill path '{{path}}' not found in repository {{owner}}/{{name}}",
"skillDirNotFound": "Skill directory not found: {{path}}",
"emptyArchive": "Downloaded archive is empty",
"downloadFailed": "Download failed: HTTP {{status}}",
"allBranchesFailed": "All branches failed, tried: {{branches}}",
"httpError": "HTTP error {{status}}",
"http403": "GitHub access restricted, possibly rate limited",
"http404": "Repository or branch not found, please check URL",
"http429": "Too many requests, please wait and retry",
"parseMetadataFailed": "Failed to parse skill metadata",
"getHomeDirFailed": "Unable to get user home directory",
"networkError": "Network error",
"fsError": "File system error",
"unknownError": "Unknown error",
"suggestion": {
"checkNetwork": "Please check network connection",
"checkProxy": "Consider configuring HTTP proxy",
"retryLater": "Please retry later",
"checkRepoUrl": "Please check repository URL and branch name",
"checkDiskSpace": "Please check disk space",
"checkPermission": "Please check directory permissions"
}
},
"repo": { "repo": {
"title": "Manage Skill Repositories", "title": "Manage Skill Repositories",
"description": "Add or remove GitHub skill repository sources", "description": "Add or remove GitHub skill repository sources",

View File

@@ -689,6 +689,34 @@
"installFailed": "安装失败", "installFailed": "安装失败",
"uninstallSuccess": "技能 {{name}} 已卸载", "uninstallSuccess": "技能 {{name}} 已卸载",
"uninstallFailed": "卸载失败", "uninstallFailed": "卸载失败",
"error": {
"skillNotFound": "技能不存在:{{directory}}",
"missingRepoInfo": "缺少仓库信息owner 或 name",
"downloadTimeout": "下载仓库 {{owner}}/{{name}} 超时({{timeout}}秒)",
"downloadTimeoutHint": "请检查网络连接或稍后重试",
"skillPathNotFound": "仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'",
"skillDirNotFound": "技能目录不存在:{{path}}",
"emptyArchive": "下载的压缩包为空",
"downloadFailed": "下载失败HTTP {{status}}",
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
"httpError": "HTTP 错误 {{status}}",
"http403": "GitHub 访问受限,可能是请求频率过高",
"http404": "仓库或分支不存在,请检查地址",
"http429": "请求过于频繁,请等待后重试",
"parseMetadataFailed": "解析技能元数据失败",
"getHomeDirFailed": "无法获取用户主目录",
"networkError": "网络错误",
"fsError": "文件系统错误",
"unknownError": "未知错误",
"suggestion": {
"checkNetwork": "请检查网络连接",
"checkProxy": "建议配置 HTTP 代理",
"retryLater": "请稍后重试",
"checkRepoUrl": "请检查仓库地址和分支名称",
"checkDiskSpace": "请检查磁盘空间",
"checkPermission": "请检查目录权限"
}
},
"repo": { "repo": {
"title": "管理技能仓库", "title": "管理技能仓库",
"description": "添加或删除 GitHub 技能仓库源", "description": "添加或删除 GitHub 技能仓库源",

View File

@@ -0,0 +1,104 @@
import { TFunction } from "i18next";
/**
* 结构化错误对象
*/
export interface SkillError {
code: string;
context: Record<string, string>;
suggestion?: string;
}
/**
* 尝试解析后端返回的错误字符串
* 如果是 JSON 格式,返回结构化错误;否则返回 null
*/
export function parseSkillError(errorString: string): SkillError | null {
try {
const parsed = JSON.parse(errorString);
if (parsed.code && parsed.context) {
return parsed as SkillError;
}
} catch {
// 不是 JSON 格式,返回 null
}
return null;
}
/**
* 将错误码映射到 i18n key
*/
function getErrorI18nKey(code: string): string {
const mapping: Record<string, string> = {
SKILL_NOT_FOUND: "skills.error.skillNotFound",
MISSING_REPO_INFO: "skills.error.missingRepoInfo",
DOWNLOAD_TIMEOUT: "skills.error.downloadTimeout",
DOWNLOAD_FAILED: "skills.error.downloadFailed",
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
EMPTY_ARCHIVE: "skills.error.emptyArchive",
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
};
return mapping[code] || "skills.error.unknownError";
}
/**
* 将建议码映射到 i18n key
*/
function getSuggestionI18nKey(suggestion: string): string {
const mapping: Record<string, string> = {
checkNetwork: "skills.error.suggestion.checkNetwork",
checkProxy: "skills.error.suggestion.checkProxy",
retryLater: "skills.error.suggestion.retryLater",
checkRepoUrl: "skills.error.suggestion.checkRepoUrl",
checkPermission: "skills.error.suggestion.checkPermission",
http403: "skills.error.http403",
http404: "skills.error.http404",
http429: "skills.error.http429",
};
return mapping[suggestion] || suggestion;
}
/**
* 格式化技能错误为用户友好的消息
* @param errorString 后端返回的错误字符串
* @param t i18next 翻译函数
* @param defaultTitle 默认标题的 i18n key如 "skills.installFailed"
* @returns 包含标题和描述的对象
*/
export function formatSkillError(
errorString: string,
t: TFunction,
defaultTitle: string = "skills.installFailed"
): { title: string; description: string } {
const parsedError = parseSkillError(errorString);
if (!parsedError) {
// 如果不是结构化错误,返回原始错误字符串
return {
title: t(defaultTitle),
description: errorString || t("common.error"),
};
}
const { code, context, suggestion } = parsedError;
// 获取错误消息的 i18n key
const errorKey = getErrorI18nKey(code);
// 构建描述(错误消息 + 建议)
let description = t(errorKey, context);
// 如果有建议,追加到描述中
if (suggestion) {
const suggestionKey = getSuggestionI18nKey(suggestion);
const suggestionText = t(suggestionKey);
description += `\n\n${suggestionText}`;
}
return {
title: t(defaultTitle),
description,
};
}