feat(skills): add install from ZIP file feature

- Add open_zip_file_dialog command for selecting ZIP files
- Add install_from_zip service method with recursive skill scanning
- Add install_skills_from_zip Tauri command
- Add frontend API methods and useInstallSkillsFromZip hook
- Add "Install from ZIP" button in Skills management page
- Support local skill ID format: local:{directory}
- Add i18n translations for new feature and error messages
This commit is contained in:
Jason
2026-01-28 11:56:36 +08:00
parent e3d335be2d
commit 987fc46e06
12 changed files with 333 additions and 3 deletions
+12
View File
@@ -15,6 +15,7 @@ import {
Search,
Download,
BarChart2,
FolderArchive,
} from "lucide-react";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
@@ -829,6 +830,17 @@ function App() {
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openInstallFromZip()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<FolderArchive className="w-4 h-4 mr-2" />
{t("skills.installFromZip.button")}
</Button>
<Button
variant="ghost"
size="sm"
+47 -1
View File
@@ -9,11 +9,12 @@ import {
useUninstallSkill,
useScanUnmanagedSkills,
useImportSkillsFromApps,
useInstallSkillsFromZip,
type InstalledSkill,
type AppType,
} from "@/hooks/useSkills";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api";
import { settingsApi, skillsApi } from "@/lib/api";
import { toast } from "sonner";
interface UnifiedSkillsPanelProps {
@@ -27,6 +28,7 @@ interface UnifiedSkillsPanelProps {
export interface UnifiedSkillsPanelHandle {
openDiscovery: () => void;
openImport: () => void;
openInstallFromZip: () => void;
}
const UnifiedSkillsPanel = React.forwardRef<
@@ -49,6 +51,7 @@ const UnifiedSkillsPanel = React.forwardRef<
const { data: unmanagedSkills, refetch: scanUnmanaged } =
useScanUnmanagedSkills();
const importMutation = useImportSkillsFromApps();
const installFromZipMutation = useInstallSkillsFromZip();
// Count enabled skills per app
const enabledCounts = useMemo(() => {
@@ -127,9 +130,52 @@ const UnifiedSkillsPanel = React.forwardRef<
}
};
const handleInstallFromZip = async () => {
try {
// 打开文件选择对话框
const filePath = await skillsApi.openZipFileDialog();
if (!filePath) {
// 用户取消选择
return;
}
// 默认使用 claude 作为当前应用
const currentApp: AppType = "claude";
// 安装 Skills
const installed = await installFromZipMutation.mutateAsync({
filePath,
currentApp,
});
if (installed.length === 0) {
toast.info(t("skills.installFromZip.noSkillsFound"), {
closeButton: true,
});
} else if (installed.length === 1) {
toast.success(
t("skills.installFromZip.successSingle", { name: installed[0].name }),
{ closeButton: true },
);
} else {
toast.success(
t("skills.installFromZip.successMultiple", {
count: installed.length,
}),
{ closeButton: true },
);
}
} catch (error) {
toast.error(t("skills.installFailed"), {
description: String(error),
});
}
};
React.useImperativeHandle(ref, () => ({
openDiscovery: onOpenDiscovery,
openImport: handleOpenImport,
openInstallFromZip: handleInstallFromZip,
}));
return (
+20
View File
@@ -147,6 +147,26 @@ export function useRemoveSkillRepo() {
});
}
/**
* 从 ZIP 文件安装 Skills
*/
export function useInstallSkillsFromZip() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
filePath,
currentApp,
}: {
filePath: string;
currentApp: AppType;
}) => skillsApi.installFromZip(filePath, currentApp),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
},
});
}
// ========== 辅助类型 ==========
export type { InstalledSkill, DiscoverableSkill, AppType };
+10 -1
View File
@@ -1054,6 +1054,7 @@
"http429": "Too many requests, please wait and retry",
"parseMetadataFailed": "Failed to parse skill metadata",
"getHomeDirFailed": "Unable to get user home directory",
"noSkillsInZip": "No skills found in ZIP file (requires SKILL.md file)",
"networkError": "Network error",
"fsError": "File system error",
"unknownError": "Unknown error",
@@ -1064,7 +1065,8 @@
"checkRepoUrl": "Please check repository URL and branch name",
"checkDiskSpace": "Please check disk space",
"checkPermission": "Please check directory permissions",
"uninstallFirst": "Please uninstall the existing skill with the same name first"
"uninstallFirst": "Please uninstall the existing skill with the same name first",
"checkZipContent": "Please verify the ZIP file contains valid skill directories (with SKILL.md files)"
}
},
"repo": {
@@ -1113,6 +1115,13 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
},
"installFromZip": {
"button": "Install from ZIP",
"installing": "Installing...",
"successSingle": "Skill {{name}} installed",
"successMultiple": "Successfully installed {{count}} skills",
"noSkillsFound": "No skills found in ZIP file (requires SKILL.md file)"
}
},
"deeplink": {
+10 -1
View File
@@ -1054,6 +1054,7 @@
"http429": "请求过于频繁,请等待后重试",
"parseMetadataFailed": "解析技能元数据失败",
"getHomeDirFailed": "无法获取用户主目录",
"noSkillsInZip": "ZIP 文件中未找到技能(需包含 SKILL.md 文件)",
"networkError": "网络错误",
"fsError": "文件系统错误",
"unknownError": "未知错误",
@@ -1064,7 +1065,8 @@
"checkRepoUrl": "请检查仓库地址和分支名称",
"checkDiskSpace": "请检查磁盘空间",
"checkPermission": "请检查目录权限",
"uninstallFirst": "请先卸载已安装的同名技能"
"uninstallFirst": "请先卸载已安装的同名技能",
"checkZipContent": "请确认 ZIP 文件包含有效的技能目录(含 SKILL.md 文件)"
}
},
"repo": {
@@ -1113,6 +1115,13 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
},
"installFromZip": {
"button": "从 ZIP 安装",
"installing": "安装中...",
"successSingle": "技能 {{name}} 已安装",
"successMultiple": "成功安装 {{count}} 个技能",
"noSkillsFound": "ZIP 文件中未找到技能(需包含 SKILL.md 文件)"
}
},
"deeplink": {
+1
View File
@@ -3,6 +3,7 @@ export { providersApi, universalProvidersApi } from "./providers";
export { settingsApi } from "./settings";
export { mcpApi } from "./mcp";
export { promptsApi } from "./prompts";
export { skillsApi } from "./skills";
export { usageApi } from "./usage";
export { vscodeApi } from "./vscode";
export { proxyApi } from "./proxy";
+15
View File
@@ -159,4 +159,19 @@ export const skillsApi = {
async removeRepo(owner: string, name: string): Promise<boolean> {
return await invoke("remove_skill_repo", { owner, name });
},
// ========== ZIP 安装 ==========
/** 打开 ZIP 文件选择对话框 */
async openZipFileDialog(): Promise<string | null> {
return await invoke("open_zip_file_dialog");
},
/** 从 ZIP 文件安装 Skills */
async installFromZip(
filePath: string,
currentApp: AppType,
): Promise<InstalledSkill[]> {
return await invoke("install_skills_from_zip", { filePath, currentApp });
},
};
+2
View File
@@ -38,6 +38,7 @@ function getErrorI18nKey(code: string): string {
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
EMPTY_ARCHIVE: "skills.error.emptyArchive",
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
NO_SKILLS_IN_ZIP: "skills.error.noSkillsInZip",
};
return mapping[code] || "skills.error.unknownError";
@@ -54,6 +55,7 @@ function getSuggestionI18nKey(suggestion: string): string {
checkRepoUrl: "skills.error.suggestion.checkRepoUrl",
checkPermission: "skills.error.suggestion.checkPermission",
uninstallFirst: "skills.error.suggestion.uninstallFirst",
checkZipContent: "skills.error.suggestion.checkZipContent",
http403: "skills.error.http403",
http404: "skills.error.http404",
http429: "skills.error.http429",