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 c76481d8f7
commit 5834b15af7
4 changed files with 370 additions and 28 deletions

View File

@@ -4,12 +4,13 @@ import {
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -46,6 +47,7 @@ import {
type Citation,
} from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
@@ -99,6 +101,8 @@ export function ArtifactFileDetail({
}, [content, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false);
useEffect(() => {
if (previewable) {
setViewMode("preview");
@@ -106,6 +110,28 @@ export function ArtifactFileDetail({
setViewMode("code");
}
}, [previewable]);
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
setIsInstalling(true);
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 {
setIsInstalling(false);
}
}, [threadId, filepath, isInstalling]);
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="px-2">
@@ -155,13 +181,13 @@ export function ArtifactFileDetail({
<div className="flex items-center gap-2">
<ArtifactActions>
{!isWriteFile && filepath.endsWith(".skill") && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={PackageIcon}
label={t.common.install}
tooltip={t.common.openInNewWindow}
/>
</a>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
tooltip={t.common.install}
disabled={isInstalling}
onClick={handleInstallSkill}
/>
)}
{!isWriteFile && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">

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({

View File

@@ -24,12 +24,39 @@ export async function enableSkill(skillName: string, enabled: boolean) {
return response.json();
}
export async function installSkill(skillName: string) {
const response = await fetch(
`${getBackendBaseURL()}/api/skills/${skillName}/install`,
{
method: "POST",
export interface InstallSkillRequest {
thread_id: string;
path: string;
}
export interface InstallSkillResponse {
success: boolean;
skill_name: string;
message: string;
}
export async function installSkill(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify(request),
});
if (!response.ok) {
// Handle HTTP error responses (4xx, 5xx)
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
return {
success: false,
skill_name: "",
message: errorMessage,
};
}
return response.json();
}