This commit is contained in:
Administrator committed 2026-06-28 00:23:01 +08:00
1 parent 65449ffd71
commit d522498cc9
8 files changed
+779 -165

No files matched your search

@@ -49,9 +49,8 @@ import {
} from "@/core/artifacts/hooks";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { streamdownPlugins } from "@/core/streamdown";
import {
checkCodeFile,
getFileName,
@@ -79,7 +78,8 @@ import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
import { SpreadsheetPreview } from "./spreadsheet-preview";
import { SpreadsheetPreview } from "./spreadsheet-preview";
import { SkillInstallDialog } from "./skill-install-dialog";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
@@ -232,7 +232,7 @@ export function ArtifactFileDetail({
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
@@ -432,29 +432,7 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
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]);
const handleSaveArtifact = useCallback(async () => {
const handleSaveArtifact = useCallback(async () => {
if (!canEditArtifact || updateArtifact.isPending) {
return;
}
@@ -686,18 +664,17 @@ export function ArtifactFileDetail({
)}
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
tooltip={t.common.install}
disabled={
isInstalling ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onClick={handleInstallSkill}
/>
</Tooltip>
)}
<ArtifactAction
icon={PackageIcon}
label={t.common.install}
tooltip={t.common.install}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onClick={() => setInstallDialogOpen(true)}
/>
</Tooltip>
)}
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
@@ -782,7 +759,7 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
@@ -859,10 +836,16 @@ export function ArtifactFileDetail({
{t.common.loading}
</div>
))}
</ArtifactContent>
</Artifact>
);
}
</ArtifactContent>
<SkillInstallDialog
open={installDialogOpen}
onOpenChange={setInstallDialogOpen}
threadId={threadId}
filepath={filepath}
/>
</Artifact>
);
}
function SandboxPreviewImage({
src,
@@ -1,6 +1,6 @@
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -10,18 +10,18 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { useI18n } from "@/core/i18n/hooks";
import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { useThread } from "../messages/context";
import { useArtifacts } from "./context";
import { useThread } from "../messages/context";
import { useArtifacts } from "./context";
import { SkillInstallDialog } from "./skill-install-dialog";
export function ArtifactFileList({
className,
@@ -32,11 +32,11 @@ export function ArtifactFileList({
files: string[];
threadId: string;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
const { select: selectArtifact, setOpen } = useArtifacts();
const [installingFile, setInstallingFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
const { select: selectArtifact, setOpen } = useArtifacts();
const [installFile, setInstallFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const handleClick = useCallback(
(filepath: string) => {
@@ -46,33 +46,14 @@ 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],
);
const handleInstallSkill = useCallback(
(e: React.MouseEvent, filepath: string) => {
e.stopPropagation();
e.preventDefault();
setInstallFile(filepath);
},
[],
);
const handleDownload = useCallback(
async (e: React.MouseEvent, filepath: string) => {
@@ -113,20 +94,15 @@ export function ArtifactFileList({
{getFileExtensionDisplayName(file)} file
</CardDescription>
<CardAction className="row-span-1 self-center">
{file.endsWith(".skill") && (
<Button
variant="ghost"
disabled={installingFile === file}
onClick={(e) => handleInstallSkill(e, file)}
>
{installingFile === file ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<PackageIcon className="size-4" />
)}
{t.common.install}
</Button>
)}
{file.endsWith(".skill") && (
<Button
variant="ghost"
onClick={(e) => handleInstallSkill(e, file)}
>
<PackageIcon className="size-4" />
{t.common.install}
</Button>
)}
<Button
variant="ghost"
disabled={downloadingFile === file}
@@ -142,7 +118,15 @@ export function ArtifactFileList({
</CardAction>
</CardHeader>
</Card>
))}
</ul>
);
}
))}
<SkillInstallDialog
open={!!installFile}
onOpenChange={(open) => {
if (!open) setInstallFile(null);
}}
threadId={threadId}
filepath={installFile}
/>
</ul>
);
}
@@ -0,0 +1,178 @@
"use client";
import { AlertCircleIcon, Loader2Icon, PackageIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
installSkill,
previewSkillInstall,
type PreviewSkillInstallResponse,
} from "@/core/skills/api";
export function SkillInstallDialog({
open,
onOpenChange,
threadId,
filepath,
onInstalled,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
threadId: string;
filepath: string | null;
onInstalled?: (skillName: string) => void;
}) {
const [preview, setPreview] = useState<PreviewSkillInstallResponse | null>(null);
const [installName, setInstallName] = useState("");
const [loading, setLoading] = useState(false);
const [installing, setInstalling] = useState(false);
useEffect(() => {
if (!open || !filepath) {
setPreview(null);
setInstallName("");
setLoading(false);
setInstalling(false);
return;
}
let cancelled = false;
setLoading(true);
setPreview(null);
setInstallName("");
void previewSkillInstall({ thread_id: threadId, path: filepath })
.then((result) => {
if (cancelled) return;
setPreview(result);
setInstallName(result.skill_name);
})
.catch((error) => {
if (cancelled) return;
toast.error(error instanceof Error ? error.message : "Failed to preview skill install");
onOpenChange(false);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [filepath, onOpenChange, open, threadId]);
const trimmedName = installName.trim();
const unchangedConflict = useMemo(() => {
if (!preview) return false;
return trimmedName === preview.skill_name && !preview.can_install_as_is;
}, [preview, trimmedName]);
async function handleInstall() {
if (!filepath || !trimmedName || installing) return;
setInstalling(true);
try {
const result = await installSkill({
thread_id: threadId,
path: filepath,
skill_name: trimmedName,
});
if (!result.success) {
toast.error(result.message || "Failed to install skill");
return;
}
toast.success(result.message);
onInstalled?.(result.skill_name);
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to install skill");
} finally {
setInstalling(false);
}
}
const helperText = preview?.owned_by_other
? `该技能名已被 ${preview.owner_username || "其他用户"} 安装。你可以换个名字继续安装。`
: preview?.owned_by_me
? "你已经安装过这个技能了;如果想保留一份新副本,可以换个名字安装。"
: preview?.builtin_conflict
? "这个名字已被公共技能占用,请换个名字安装。"
: "可以直接安装,也可以先改一个更适合自己的名字。";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>安装技能</DialogTitle>
<DialogDescription>安装前会校验技能归属;如果名字已被占用,可以改名后继续安装。</DialogDescription>
</DialogHeader>
{loading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在检查技能安装权限…
</div>
) : preview ? (
<div className="space-y-4">
<div className="rounded-xl border bg-muted/35 p-3 text-sm">
<div className="font-medium">{preview.skill_name}</div>
{preview.description ? (
<div className="text-muted-foreground mt-1 line-clamp-3">{preview.description}</div>
) : null}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">安装名称</div>
<Input
value={installName}
onChange={(e) => setInstallName(e.currentTarget.value)}
placeholder="请输入技能名称"
/>
<div className="text-muted-foreground text-xs">{helperText}</div>
</div>
{!preview.can_install_as_is && (
<div className="flex gap-2 rounded-xl border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<AlertCircleIcon className="mt-0.5 size-4 shrink-0" />
<div>{preview.message}</div>
</div>
)}
{preview.warnings.length > 0 && (
<div className="text-muted-foreground rounded-xl border px-3 py-2 text-xs">
{preview.warnings.join(" ")}
</div>
)}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={installing}>
取消
</Button>
<Button
onClick={() => void handleInstall()}
disabled={loading || installing || !trimmedName || unchangedConflict}
>
{installing ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PackageIcon className="size-4" />
)}
安装
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -8,7 +8,7 @@ function makeSources(count: number): RuntimeDisplaySource[] {
index: position + 1,
skillName: "knowledge-base-search",
mode: "citation",
title: `参考文献 ${position + 1}`,
title: `Reference ${position + 1}`,
type: "result",
snippet: "",
raw: {},
@@ -19,7 +19,7 @@ function citation(index: number): string {
return `[citation:${index}](#reference-${index})`;
}
test("全角单编号 【1】【2】 解析为引用徽章", () => {
test("converts full-width numbered references", () => {
const out = applyInlineCitations("结论见【1】【2】。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
@@ -27,21 +27,21 @@ test("全角单编号 【1】【2】 解析为引用徽章", () => {
assert.equal(out.includes("【2】"), false);
});
test("全角数字 【1】 也能识别", () => {
test("converts full-width digits", () => {
const out = applyInlineCitations("参考【1】和【2】。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
test("全角括号合并编号 【1,2】/【1,2】/【1、2】 拆成多个引用", () => {
for (const marker of ["【1,2】", "【1,2】", "【1、2】"]) {
test("splits merged reference markers", () => {
for (const marker of ["【1,2】", "【1、2】", "【1,2】"]) {
const out = applyInlineCitations(`见${marker}。`, makeSources(2));
assert.equal(out.includes(citation(1)), true, marker);
assert.equal(out.includes(citation(2)), true, marker);
}
});
test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
test("expands reference ranges", () => {
const dash = applyInlineCitations("见【1-3】。", makeSources(3));
assert.equal(dash.includes(citation(1)), true);
assert.equal(dash.includes(citation(2)), true);
@@ -53,21 +53,26 @@ test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
assert.equal(tilde.includes(citation(4)), true);
});
test("超出来源范围的全角编号保持原样,不误判正文", () => {
// 只有 1 条来源,【3】 没有对应来源 → 不应转换
test("does not rewrite out-of-range references", () => {
const out = applyInlineCitations("正文【3】不是引用。", makeSources(1));
assert.equal(out.includes("【3】"), true);
assert.equal(out.includes("#reference-3"), false);
});
test("非数字的全角括号不被改写", () => {
test("does not rewrite non-numeric bracket text", () => {
const out = applyInlineCitations("见【附录】小节。", makeSources(2));
assert.equal(out.includes("【附录】"), true);
});
test("半角写法仍然正常工作", () => {
test("keeps half-width syntax working", () => {
const out = applyInlineCitations("见[1][2]和[1,3]。", makeSources(3));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
assert.equal(out.includes(citation(3)), true);
});
test("accepts spaced and full-width square brackets", () => {
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import type { Skill } from "@/core/skills/type";
import { buildSkillDisplayMap, collectRuntimeDisplaySources } from "./runtime";
function makeSkill(): Skill {
return {
name: "custom-search",
description: "test",
category: "test",
license: "internal",
enabled: true,
display: {
enabled: true,
mode: "citation",
resultPath: "docs",
titleField: "title",
summaryField: "summary",
contentField: "content",
sourceField: "source",
authorField: null,
typeField: null,
timeField: null,
scoreField: null,
urlField: "url",
idField: "id",
linkStrategy: "auto",
linkTemplate: null,
citationToken: "doubleBracket",
maxItems: 20,
customInstruction: "",
tableColumns: [],
resultSets: [],
},
};
}
function makeMessages(toolContent: Message["content"]): Message[] {
return [
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [{ id: "call-1", name: "custom-search", args: {} }],
} as unknown as Message,
{
id: "tool-1",
type: "tool",
name: "custom-search",
tool_call_id: "call-1",
content: toolContent,
} as unknown as Message,
];
}
test("collects citations from fenced nested json", () => {
const messages = makeMessages(
"```json\n{\"payload\":{\"docs\":[{\"id\":\"doc-1\",\"title\":\"Alpha\",\"summary\":\"Brief\",\"content\":\"Body\",\"source\":\"KB\",\"url\":\"https://example.com/a\"}]}}\n```",
);
const sources = collectRuntimeDisplaySources(
messages,
buildSkillDisplayMap([makeSkill()]),
);
assert.equal(sources.length, 1);
assert.equal(sources[0]?.title, "Alpha");
assert.equal(sources[0]?.url, "https://example.com/a");
});
test("collects citations from structured tool payload arrays", () => {
const messages = makeMessages([
{
type: "json",
json: {
wrapper: {
docs: [
{
id: "doc-2",
title: "Beta",
summary: "Second",
content: "Payload",
source: "Skill",
url: "https://example.com/b",
},
],
},
},
},
] as unknown as Message["content"]);
const sources = collectRuntimeDisplaySources(
messages,
buildSkillDisplayMap([makeSkill()]),
);
assert.equal(sources.length, 1);
assert.equal(sources[0]?.title, "Beta");
assert.equal(sources[0]?.source, "Skill");
});
+146 -26
View File
@@ -588,7 +588,7 @@ function replaceBracketCitationTokens(
byIndex: Map<number, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string {
const tokenPattern = /\\?\[(\d{1,3})\\?\](?!\()/g;
const tokenPattern = /\\?\[\s*([0-9\uFF10-\uFF19]{1,3})\s*\\?\](?!\()/g;
let cursor = 0;
let next = "";
for (const match of content.matchAll(tokenPattern)) {
@@ -598,13 +598,24 @@ function replaceBracketCitationTokens(
continue;
}
next += content.slice(cursor, start);
next += citationMarkdown(match[0], Number(match[1]), byIndex, orderedSources);
next += citationMarkdown(
match[0],
Number(normalizeAsciiDigits(match[1] ?? "")),
byIndex,
orderedSources,
);
cursor = start + match[0].length;
}
next += content.slice(cursor);
return next;
}
function normalizeAsciiDigits(value: string): string {
return value.replace(/[\uFF10-\uFF19]/g, (digit) =>
String.fromCharCode(digit.charCodeAt(0) - 0xfee0),
);
}
function normalizeLooseCitationTokens(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
@@ -637,13 +648,17 @@ function normalizeLooseCitationTokens(
// 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。
// 全角单编号:【1】/【1】
let next = content.replace(
let next = content
.replace(/[/g, "[")
.replace(/]/g, "]")
.replace(/[,、]/g, ",")
.replace(
/【\s*([0-90-9]{1,3})\s*】/g,
(match, rawIndex: string) => {
const index = Number(toAsciiDigits(rawIndex));
const index = Number(normalizeAsciiDigits(rawIndex));
return isValidIndex(index) ? `[${index}]` : match;
},
);
);
// 全角合并编号:【1,2】/【1,2】/【1、2】
next = next.replace(
@@ -651,7 +666,7 @@ function normalizeLooseCitationTokens(
(match, rawList: string) => {
const indexes = rawList
.split(/[,,、]/)
.map((part) => Number(toAsciiDigits(part.trim())));
.map((part) => Number(normalizeAsciiDigits(part.trim())));
if (indexes.some((index) => !isValidIndex(index))) {
return match;
}
@@ -663,8 +678,8 @@ function normalizeLooseCitationTokens(
next = next.replace(
/【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g,
(match, rawStart: string, rawEnd: string) => {
const start = Number(toAsciiDigits(rawStart));
const end = Number(toAsciiDigits(rawEnd));
const start = Number(normalizeAsciiDigits(rawStart));
const end = Number(normalizeAsciiDigits(rawEnd));
if (
end <= start ||
end - start > 20 ||
@@ -978,20 +993,23 @@ function extractStructuredToolPayload(content: unknown): unknown {
}
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
if (
Array.isArray(value.results) ||
Array.isArray(value.data) ||
Array.isArray(value.items)
) {
return value;
const directCandidate = findDisplayPayloadCandidate(value, 0);
if (directCandidate !== undefined) {
return directCandidate;
}
for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) {
const nested = value[key];
for (const nested of Object.values(value)) {
if (typeof nested === "string") {
const parsed = parseJSONLikeText(nested);
if (parsed !== undefined) {
if (isRecord(parsed)) {
const structured = findDisplayPayloadCandidate(parsed, 0);
if (structured !== undefined) {
return structured;
}
}
return parsed;
}
continue;
}
if (isRecord(nested)) {
const unwrapped = unwrapToolPayloadObject(nested);
@@ -1008,6 +1026,14 @@ function parseJSONLikeText(text: string): unknown {
if (!trimmed) {
return undefined;
}
const fenced = extractFencedJSON(trimmed);
if (fenced) {
try {
return JSON.parse(fenced) as unknown;
} catch {
// Fall through to best-effort parsing.
}
}
try {
return JSON.parse(trimmed) as unknown;
} catch {
@@ -1016,17 +1042,9 @@ function parseJSONLikeText(text: string): unknown {
}
function extractJSONFromText(text: string): unknown {
const candidates: Array<[number, number]> = [];
const firstObj = text.indexOf("{");
if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]);
const firstArr = text.indexOf("[");
if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]);
candidates.sort(([a], [b]) => a - b);
for (const [start, end] of candidates) {
if (start < 0 || end <= start) continue;
const slice = text.slice(start, end + 1);
for (const candidate of iterateBalancedJsonCandidates(text)) {
try {
return JSON.parse(slice);
return JSON.parse(candidate);
} catch {
// try the next candidate
}
@@ -1034,6 +1052,108 @@ function extractJSONFromText(text: string): unknown {
return undefined;
}
function *iterateBalancedJsonCandidates(text: string): Generator<string> {
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (char !== "{" && char !== "[") {
continue;
}
const candidate = extractBalancedJson(text, index);
if (candidate) {
yield candidate;
}
}
}
function extractBalancedJson(text: string, start: number): string | null {
const opening = text[start];
if (opening !== "{" && opening !== "[") {
return null;
}
const closing = opening === "{" ? "}" : "]";
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === opening) {
depth += 1;
continue;
}
if (char === closing) {
depth -= 1;
if (depth === 0) {
return text.slice(start, index + 1);
}
}
}
return null;
}
function extractFencedJSON(text: string): string | null {
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
return match?.[1]?.trim() ?? null;
}
function findDisplayPayloadCandidate(value: unknown, depth: number): unknown {
if (depth > 8) {
return undefined;
}
if (Array.isArray(value)) {
const objectItems = value.filter(
(item): item is Record<string, unknown> =>
isRecord(item),
);
if (value.length === 0 || objectItems.length === value.length) {
return { results: objectItems };
}
for (const item of value) {
const nested = findDisplayPayloadCandidate(item, depth + 1);
if (nested !== undefined) {
return nested;
}
}
return undefined;
}
if (!isRecord(value)) {
return undefined;
}
for (const key of ["results", "data", "items", "docs", "records", "rows", "list", "hits", "sources"]) {
const nested = value[key];
if (!Array.isArray(nested)) {
continue;
}
const objectItems = nested.filter(
(item): item is Record<string, unknown> =>
isRecord(item),
);
if (nested.length === 0 || objectItems.length === nested.length) {
return { ...value, [key]: objectItems };
}
}
for (const nested of Object.values(value)) {
const candidate = findDisplayPayloadCandidate(nested, depth + 1);
if (candidate !== undefined) {
return candidate;
}
}
return undefined;
}
function extractSearchPayloadFromText(text: string): unknown {
if (!text.includes('"results"') || !text.includes('"recUuid"')) {
return undefined;
+54 -16
View File
@@ -141,17 +141,39 @@ export async function getSkillDisplaySample(
return response.json() as Promise<SkillDisplaySampleResponse>;
}
export interface InstallSkillRequest {
thread_id: string;
path: string;
}
export interface InstallSkillResponse {
success: boolean;
skill_name: string;
message: string;
overwritten?: boolean;
}
export interface InstallSkillRequest {
thread_id: string;
path: string;
skill_name?: string;
}
export interface InstallSkillResponse {
success: boolean;
skill_name: string;
message: string;
overwritten?: boolean;
}
export interface PreviewSkillInstallRequest {
thread_id: string;
path: string;
}
export interface PreviewSkillInstallResponse {
ok: boolean;
skill_name: string;
description: string;
license: string | null;
allowed_tools: string[];
can_install_as_is: boolean;
owned_by_me: boolean;
owned_by_other: boolean;
builtin_conflict: boolean;
owner_username?: string | null;
owner_user_id?: string | null;
message: string;
warnings: string[];
}
export interface ValidateSkillUploadResponse {
ok: boolean;
@@ -164,9 +186,9 @@ export interface ValidateSkillUploadResponse {
warnings: string[];
}
export async function installSkill(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
export async function installSkill(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install`, {
method: "POST",
headers: {
@@ -187,8 +209,24 @@ export async function installSkill(
};
}
return response.json();
}
return response.json();
}
export async function previewSkillInstall(
request: PreviewSkillInstallRequest,
): Promise<PreviewSkillInstallResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install-preview`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw await parseDetailedError(response, "Failed to preview skill install");
}
return response.json() as Promise<PreviewSkillInstallResponse>;
}
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
const data = await response.json().catch(() => ({}));
@@ -4,9 +4,11 @@ import logging
import mimetypes
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -220,6 +222,7 @@ class SkillInstallRequest(BaseModel):
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
skill_name: str | None = Field(default=None, description="Optional custom install name. When set, rewrites the archive SKILL.md frontmatter name before install.")
target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
@@ -233,6 +236,32 @@ class SkillInstallResponse(BaseModel):
category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'")
class SkillInstallPreviewRequest(BaseModel):
"""Request model for preflighting a .skill artifact install."""
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
path: str = Field(..., description="Virtual path to the .skill file")
target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
class SkillInstallPreviewResponse(BaseModel):
"""Response model for skill install preflight."""
ok: bool = Field(..., description="Whether the archive could be parsed")
skill_name: str = Field(..., description="Skill name parsed from the archive frontmatter")
description: str = Field(default="", description="Parsed description from the archive frontmatter")
license: str | None = Field(default=None, description="Parsed license from the archive frontmatter")
allowed_tools: list[str] = Field(default_factory=list, description="Allowed tools declared in the archive")
can_install_as_is: bool = Field(default=True, description="Whether the current user may install the archive without renaming")
owned_by_me: bool = Field(default=False, description="True when the existing conflicting skill is already owned by the caller")
owned_by_other: bool = Field(default=False, description="True when another user already owns the conflicting custom skill")
builtin_conflict: bool = Field(default=False, description="True when the parsed name collides with a built-in/public skill")
owner_username: str | None = Field(default=None, description="Friendly owner username for owned_by_other conflicts")
owner_user_id: str | None = Field(default=None, description="Owner user id for custom conflicts")
message: str = Field(default="", description="Human-readable install guidance for the UI")
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings from validation")
class SkillUploadValidateResponse(BaseModel):
"""Response model for the pre-install validation endpoint."""
@@ -1040,6 +1069,162 @@ async def install_skill_upload(
tmp_path.unlink(missing_ok=True)
async def _preview_install_target(
*,
request: Request,
skill_store: SkillStore,
skill_name: str,
target_category: SkillCategory,
) -> tuple[bool, bool, bool, bool, str | None, str | None, str]:
"""Return installability / ownership info for a parsed skill name."""
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
existing = await skill_store.get_any(skill_name)
owner_user_id = existing.get("owner_user_id") if existing else None
owner_username: str | None = None
if owner_user_id:
try:
attached = await _attach_owner_usernames(
[SkillResponse(name=skill_name, description="", license=None, category=SkillCategory.CUSTOM, owner_user_id=owner_user_id)]
)
owner_username = attached[0].owner_username if attached else None
except Exception:
owner_username = None
builtin_conflict = bool(existing and owner_user_id is None)
owned_by_me = bool(existing and owner_user_id == user_id)
owned_by_other = bool(existing and owner_user_id not in (None, user_id))
if target_category == SkillCategory.PUBLIC and not is_admin:
return False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, "Only admin users can install skills to the public directory"
if builtin_conflict:
return False, owned_by_me, owned_by_other, True, owner_user_id, owner_username, f"Skill '{skill_name}' already exists as a built-in/public skill. Please install it under a different name."
if owned_by_other:
who = owner_username or "another user"
return False, owned_by_me, True, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' has already been installed by {who}. Please choose a different name."
if owned_by_me:
return False, True, False, builtin_conflict, owner_user_id, owner_username, f"You have already installed skill '{skill_name}'. You can keep using it or install this copy under a different name."
return True, False, False, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed."
async def _build_renamed_skill_archive(
archive_path: str | Path,
*,
new_name: str,
require_skill_suffix: bool,
) -> Path:
"""Create a temporary archive whose SKILL.md frontmatter name is rewritten."""
from deerflow.skills.installer import resolve_skill_dir_from_archive, safe_extract_skill_archive
SkillStorage.validate_skill_name(new_name)
tmp_root = Path(tempfile.mkdtemp(prefix="skill-install-rename-"))
try:
path = Path(archive_path)
if require_skill_suffix and path.suffix != ".skill":
raise ValueError("File must have .skill extension")
with zipfile.ZipFile(path, "r") as zf:
safe_extract_skill_archive(zf, tmp_root)
skill_dir = resolve_skill_dir_from_archive(tmp_root)
skill_md = skill_dir / SKILL_MD_FILE
skill_md.write_text(
_rewrite_skill_frontmatter_name(skill_md.read_text(encoding="utf-8"), new_name),
encoding="utf-8",
)
renamed_archive = tmp_root / f"{new_name}.skill"
with zipfile.ZipFile(renamed_archive, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in sorted(tmp_root.rglob("*")):
if not path.is_file() or path == renamed_archive:
continue
zf.write(path, path.relative_to(tmp_root))
return renamed_archive
except Exception:
shutil.rmtree(tmp_root, ignore_errors=True)
raise
async def _install_archive_with_optional_name(
*,
storage: SkillStorage,
archive_path: str | Path,
requested_name: str | None,
target_category: SkillCategory,
overwrite: bool = False,
require_skill_suffix: bool = True,
) -> SkillInstallResponse:
normalized_requested = SkillStorage.validate_skill_name(requested_name) if requested_name else None
temp_root: Path | None = None
install_source = Path(archive_path)
if normalized_requested:
renamed_archive = await _build_renamed_skill_archive(
archive_path,
new_name=normalized_requested,
require_skill_suffix=require_skill_suffix,
)
temp_root = renamed_archive.parent
install_source = renamed_archive
try:
result = await storage.ainstall_skill_from_archive(
install_source,
overwrite=overwrite,
require_skill_suffix=require_skill_suffix,
target_category=target_category,
)
return SkillInstallResponse(**result)
finally:
if temp_root is not None:
shutil.rmtree(temp_root, ignore_errors=True)
@router.post(
"/skills/install-preview",
response_model=SkillInstallPreviewResponse,
summary="Preview Skill Install",
description="Parse a .skill artifact from a thread and return ownership/conflict info before install.",
)
async def preview_skill_install(
body: SkillInstallPreviewRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillInstallPreviewResponse:
try:
skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path)
validation = get_or_new_skill_storage(app_config=config).validate_skill_archive(skill_file_path, require_skill_suffix=False)
skill_name = str(validation.get("skill_name") or "")
can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message = await _preview_install_target(
request=request,
skill_store=skill_store,
skill_name=skill_name,
target_category=body.target_category,
)
return SkillInstallPreviewResponse(
ok=True,
skill_name=skill_name,
description=str(validation.get("description") or ""),
license=validation.get("license"),
allowed_tools=[str(item) for item in (validation.get("allowed_tools") or [])],
can_install_as_is=can_install_as_is,
owned_by_me=owned_by_me,
owned_by_other=owned_by_other,
builtin_conflict=builtin_conflict,
owner_username=owner_username,
owner_user_id=owner_user_id,
message=message,
warnings=[str(item) for item in (validation.get("warnings") or [])],
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to preview skill install: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to preview skill install: {e}")
@router.post(
"/skills/install",
response_model=SkillInstallResponse,
@@ -1056,25 +1241,46 @@ async def install_skill(
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
target_category = body.target_category
requested_name = SkillStorage.validate_skill_name(body.skill_name) if body.skill_name else None
if target_category == SkillCategory.PUBLIC and not is_admin:
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path)
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
storage = get_or_new_skill_storage(app_config=config)
validation = storage.validate_skill_archive(skill_file_path, require_skill_suffix=False)
preview_name = requested_name or str(validation.get("skill_name") or "")
can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, _owner_user_id, _owner_username, message = await _preview_install_target(
request=request,
skill_store=skill_store,
skill_name=preview_name,
target_category=target_category,
)
if not can_install_as_is:
if requested_name and requested_name != validation.get("skill_name") and not owned_by_other and not builtin_conflict:
pass
else:
raise HTTPException(status_code=409, detail=message)
result = await _install_archive_with_optional_name(
storage=storage,
archive_path=skill_file_path,
requested_name=requested_name,
target_category=target_category,
require_skill_suffix=False,
)
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"])
mark_uploaded(result.skill_name)
except Exception:
pass
if target_category == SkillCategory.PUBLIC:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": None, "published": True}
{"name": result.skill_name, "owner_user_id": None, "published": True}
)
else:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
{"name": result.skill_name, "owner_user_id": user_id, "published": False}
)
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result)
return result
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except SkillAlreadyExistsError as e: