mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-17 19:44:45 +08:00
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:
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user