feat: add skill installation API endpoint

Add POST /api/skills/install endpoint to install .skill files from
thread's user-data directory. The endpoint extracts the ZIP archive,
validates SKILL.md frontmatter, and installs to skills/custom/.

Frontend Install buttons now call the API instead of downloading.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hetaoBackend
2026-01-31 22:10:05 +08:00
parent 3c3bf901e7
commit 624f758163
4 changed files with 370 additions and 28 deletions

View File

@@ -1,5 +1,6 @@
import { DownloadIcon, PackageIcon } from "lucide-react";
import { useCallback } from "react";
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -11,6 +12,7 @@ import {
} from "@/components/ui/card";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import { getFileExtensionDisplayName, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
@@ -27,6 +29,8 @@ export function ArtifactFileList({
}) {
const { t } = useI18n();
const { select: selectArtifact, setOpen } = useArtifacts();
const [installingFile, setInstallingFile] = useState<string | null>(null);
const handleClick = useCallback(
(filepath: string) => {
selectArtifact(filepath);
@@ -34,6 +38,35 @@ export function ArtifactFileList({
},
[selectArtifact, setOpen],
);
const handleInstallSkill = useCallback(
async (e: React.MouseEvent, filepath: string) => {
e.stopPropagation();
e.preventDefault();
if (installingFile) return;
setInstallingFile(filepath);
try {
const result = await installSkill({
thread_id: threadId,
path: filepath,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message || "Failed to install skill");
}
} catch (error) {
console.error("Failed to install skill:", error);
toast.error("Failed to install skill");
} finally {
setInstallingFile(null);
}
},
[threadId, installingFile],
);
return (
<ul className={cn("flex w-full flex-col gap-4", className)}>
{files.map((file) => (
@@ -49,20 +82,18 @@ export function ArtifactFileList({
</CardDescription>
<CardAction>
{file.endsWith(".skill") && (
<a
href={urlOfArtifact({
filepath: file,
threadId: threadId,
download: true,
})}
target="_blank"
onClick={(e) => e.stopPropagation()}
<Button
variant="ghost"
disabled={installingFile === file}
onClick={(e) => handleInstallSkill(e, file)}
>
<Button variant="ghost">
{installingFile === file ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<PackageIcon className="size-4" />
{t.common.install}
</Button>
</a>
)}
{t.common.install}
</Button>
)}
<a
href={urlOfArtifact({