ji
This commit is contained in:
1 parent
65449ffd71
commit
d522498cc9
8 files changed
+712
-98
No files matched your search
@@ -50,7 +50,6 @@ import {
|
|||||||
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
import { installSkill } from "@/core/skills/api";
|
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import {
|
import {
|
||||||
checkCodeFile,
|
checkCodeFile,
|
||||||
@@ -80,6 +79,7 @@ import { Tooltip } from "../tooltip";
|
|||||||
|
|
||||||
import { useArtifacts } from "./context";
|
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";
|
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ export function ArtifactFileDetail({
|
|||||||
|
|
||||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||||
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
||||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||||
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
|
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
|
||||||
@@ -432,28 +432,6 @@ export function ArtifactFileDetail({
|
|||||||
}, 1600);
|
}, 1600);
|
||||||
}, [scrollArtifactToBottom]);
|
}, [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) {
|
if (!canEditArtifact || updateArtifact.isPending) {
|
||||||
return;
|
return;
|
||||||
@@ -687,14 +665,13 @@ export function ArtifactFileDetail({
|
|||||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
icon={PackageIcon}
|
||||||
label={t.common.install}
|
label={t.common.install}
|
||||||
tooltip={t.common.install}
|
tooltip={t.common.install}
|
||||||
disabled={
|
disabled={
|
||||||
isInstalling ||
|
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||||
}
|
}
|
||||||
onClick={handleInstallSkill}
|
onClick={() => setInstallDialogOpen(true)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -860,6 +837,12 @@ export function ArtifactFileDetail({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ArtifactContent>
|
</ArtifactContent>
|
||||||
|
<SkillInstallDialog
|
||||||
|
open={installDialogOpen}
|
||||||
|
onOpenChange={setInstallDialogOpen}
|
||||||
|
threadId={threadId}
|
||||||
|
filepath={filepath}
|
||||||
|
/>
|
||||||
</Artifact>
|
</Artifact>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { downloadArtifactFile } from "@/core/artifacts/download";
|
import { downloadArtifactFile } from "@/core/artifacts/download";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { installSkill } from "@/core/skills/api";
|
|
||||||
import {
|
import {
|
||||||
getFileExtensionDisplayName,
|
getFileExtensionDisplayName,
|
||||||
getFileIcon,
|
getFileIcon,
|
||||||
@@ -22,6 +21,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
import { useThread } from "../messages/context";
|
import { useThread } from "../messages/context";
|
||||||
import { useArtifacts } from "./context";
|
import { useArtifacts } from "./context";
|
||||||
|
import { SkillInstallDialog } from "./skill-install-dialog";
|
||||||
|
|
||||||
export function ArtifactFileList({
|
export function ArtifactFileList({
|
||||||
className,
|
className,
|
||||||
@@ -35,7 +35,7 @@ export function ArtifactFileList({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isMock, publicShareCode } = useThread();
|
const { isMock, publicShareCode } = useThread();
|
||||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||||
const [installingFile, setInstallingFile] = useState<string | null>(null);
|
const [installFile, setInstallFile] = useState<string | null>(null);
|
||||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
@@ -47,31 +47,12 @@ export function ArtifactFileList({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleInstallSkill = useCallback(
|
const handleInstallSkill = useCallback(
|
||||||
async (e: React.MouseEvent, filepath: string) => {
|
(e: React.MouseEvent, filepath: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setInstallFile(filepath);
|
||||||
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 handleDownload = useCallback(
|
const handleDownload = useCallback(
|
||||||
@@ -116,14 +97,9 @@ export function ArtifactFileList({
|
|||||||
{file.endsWith(".skill") && (
|
{file.endsWith(".skill") && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={installingFile === file}
|
|
||||||
onClick={(e) => handleInstallSkill(e, file)}
|
onClick={(e) => handleInstallSkill(e, file)}
|
||||||
>
|
>
|
||||||
{installingFile === file ? (
|
<PackageIcon className="size-4" />
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<PackageIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
{t.common.install}
|
{t.common.install}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -143,6 +119,14 @@ export function ArtifactFileList({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
<SkillInstallDialog
|
||||||
|
open={!!installFile}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setInstallFile(null);
|
||||||
|
}}
|
||||||
|
threadId={threadId}
|
||||||
|
filepath={installFile}
|
||||||
|
/>
|
||||||
</ul>
|
</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,
|
index: position + 1,
|
||||||
skillName: "knowledge-base-search",
|
skillName: "knowledge-base-search",
|
||||||
mode: "citation",
|
mode: "citation",
|
||||||
title: `参考文献 ${position + 1}`,
|
title: `Reference ${position + 1}`,
|
||||||
type: "result",
|
type: "result",
|
||||||
snippet: "",
|
snippet: "",
|
||||||
raw: {},
|
raw: {},
|
||||||
@@ -19,7 +19,7 @@ function citation(index: number): string {
|
|||||||
return `[citation:${index}](#reference-${index})`;
|
return `[citation:${index}](#reference-${index})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("全角单编号 【1】【2】 解析为引用徽章", () => {
|
test("converts full-width numbered references", () => {
|
||||||
const out = applyInlineCitations("结论见【1】【2】。", makeSources(2));
|
const out = applyInlineCitations("结论见【1】【2】。", makeSources(2));
|
||||||
assert.equal(out.includes(citation(1)), true);
|
assert.equal(out.includes(citation(1)), true);
|
||||||
assert.equal(out.includes(citation(2)), true);
|
assert.equal(out.includes(citation(2)), true);
|
||||||
@@ -27,21 +27,21 @@ test("全角单编号 【1】【2】 解析为引用徽章", () => {
|
|||||||
assert.equal(out.includes("【2】"), false);
|
assert.equal(out.includes("【2】"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("全角数字 【1】 也能识别", () => {
|
test("converts full-width digits", () => {
|
||||||
const out = applyInlineCitations("参考【1】和【2】。", makeSources(2));
|
const out = applyInlineCitations("参考【1】和【2】。", makeSources(2));
|
||||||
assert.equal(out.includes(citation(1)), true);
|
assert.equal(out.includes(citation(1)), true);
|
||||||
assert.equal(out.includes(citation(2)), true);
|
assert.equal(out.includes(citation(2)), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("全角括号合并编号 【1,2】/【1,2】/【1、2】 拆成多个引用", () => {
|
test("splits merged reference markers", () => {
|
||||||
for (const marker of ["【1,2】", "【1,2】", "【1、2】"]) {
|
for (const marker of ["【1,2】", "【1、2】", "【1,2】"]) {
|
||||||
const out = applyInlineCitations(`见${marker}。`, makeSources(2));
|
const out = applyInlineCitations(`见${marker}。`, makeSources(2));
|
||||||
assert.equal(out.includes(citation(1)), true, marker);
|
assert.equal(out.includes(citation(1)), true, marker);
|
||||||
assert.equal(out.includes(citation(2)), 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));
|
const dash = applyInlineCitations("见【1-3】。", makeSources(3));
|
||||||
assert.equal(dash.includes(citation(1)), true);
|
assert.equal(dash.includes(citation(1)), true);
|
||||||
assert.equal(dash.includes(citation(2)), true);
|
assert.equal(dash.includes(citation(2)), true);
|
||||||
@@ -53,21 +53,26 @@ test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
|
|||||||
assert.equal(tilde.includes(citation(4)), true);
|
assert.equal(tilde.includes(citation(4)), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("超出来源范围的全角编号保持原样,不误判正文", () => {
|
test("does not rewrite out-of-range references", () => {
|
||||||
// 只有 1 条来源,【3】 没有对应来源 → 不应转换
|
|
||||||
const out = applyInlineCitations("正文【3】不是引用。", makeSources(1));
|
const out = applyInlineCitations("正文【3】不是引用。", makeSources(1));
|
||||||
assert.equal(out.includes("【3】"), true);
|
assert.equal(out.includes("【3】"), true);
|
||||||
assert.equal(out.includes("#reference-3"), false);
|
assert.equal(out.includes("#reference-3"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("非数字的全角括号不被改写", () => {
|
test("does not rewrite non-numeric bracket text", () => {
|
||||||
const out = applyInlineCitations("见【附录】小节。", makeSources(2));
|
const out = applyInlineCitations("见【附录】小节。", makeSources(2));
|
||||||
assert.equal(out.includes("【附录】"), true);
|
assert.equal(out.includes("【附录】"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("半角写法仍然正常工作", () => {
|
test("keeps half-width syntax working", () => {
|
||||||
const out = applyInlineCitations("见[1][2]和[1,3]。", makeSources(3));
|
const out = applyInlineCitations("见[1][2]和[1,3]。", makeSources(3));
|
||||||
assert.equal(out.includes(citation(1)), true);
|
assert.equal(out.includes(citation(1)), true);
|
||||||
assert.equal(out.includes(citation(2)), true);
|
assert.equal(out.includes(citation(2)), true);
|
||||||
assert.equal(out.includes(citation(3)), 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");
|
||||||
|
});
|
||||||
@@ -588,7 +588,7 @@ function replaceBracketCitationTokens(
|
|||||||
byIndex: Map<number, RuntimeDisplaySource>,
|
byIndex: Map<number, RuntimeDisplaySource>,
|
||||||
orderedSources: RuntimeDisplaySource[],
|
orderedSources: RuntimeDisplaySource[],
|
||||||
): string {
|
): string {
|
||||||
const tokenPattern = /\\?\[(\d{1,3})\\?\](?!\()/g;
|
const tokenPattern = /\\?\[\s*([0-9\uFF10-\uFF19]{1,3})\s*\\?\](?!\()/g;
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
let next = "";
|
let next = "";
|
||||||
for (const match of content.matchAll(tokenPattern)) {
|
for (const match of content.matchAll(tokenPattern)) {
|
||||||
@@ -598,13 +598,24 @@ function replaceBracketCitationTokens(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
next += content.slice(cursor, start);
|
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;
|
cursor = start + match[0].length;
|
||||||
}
|
}
|
||||||
next += content.slice(cursor);
|
next += content.slice(cursor);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAsciiDigits(value: string): string {
|
||||||
|
return value.replace(/[\uFF10-\uFF19]/g, (digit) =>
|
||||||
|
String.fromCharCode(digit.charCodeAt(0) - 0xfee0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLooseCitationTokens(
|
function normalizeLooseCitationTokens(
|
||||||
content: string,
|
content: string,
|
||||||
byIndex: Map<number, RuntimeDisplaySource>,
|
byIndex: Map<number, RuntimeDisplaySource>,
|
||||||
@@ -637,13 +648,17 @@ function normalizeLooseCitationTokens(
|
|||||||
// 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。
|
// 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。
|
||||||
|
|
||||||
// 全角单编号:【1】/【1】
|
// 全角单编号:【1】/【1】
|
||||||
let next = content.replace(
|
let next = content
|
||||||
|
.replace(/[/g, "[")
|
||||||
|
.replace(/]/g, "]")
|
||||||
|
.replace(/[,、]/g, ",")
|
||||||
|
.replace(
|
||||||
/【\s*([0-90-9]{1,3})\s*】/g,
|
/【\s*([0-90-9]{1,3})\s*】/g,
|
||||||
(match, rawIndex: string) => {
|
(match, rawIndex: string) => {
|
||||||
const index = Number(toAsciiDigits(rawIndex));
|
const index = Number(normalizeAsciiDigits(rawIndex));
|
||||||
return isValidIndex(index) ? `[${index}]` : match;
|
return isValidIndex(index) ? `[${index}]` : match;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 全角合并编号:【1,2】/【1,2】/【1、2】
|
// 全角合并编号:【1,2】/【1,2】/【1、2】
|
||||||
next = next.replace(
|
next = next.replace(
|
||||||
@@ -651,7 +666,7 @@ function normalizeLooseCitationTokens(
|
|||||||
(match, rawList: string) => {
|
(match, rawList: string) => {
|
||||||
const indexes = rawList
|
const indexes = rawList
|
||||||
.split(/[,,、]/)
|
.split(/[,,、]/)
|
||||||
.map((part) => Number(toAsciiDigits(part.trim())));
|
.map((part) => Number(normalizeAsciiDigits(part.trim())));
|
||||||
if (indexes.some((index) => !isValidIndex(index))) {
|
if (indexes.some((index) => !isValidIndex(index))) {
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
@@ -663,8 +678,8 @@ function normalizeLooseCitationTokens(
|
|||||||
next = next.replace(
|
next = next.replace(
|
||||||
/【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g,
|
/【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g,
|
||||||
(match, rawStart: string, rawEnd: string) => {
|
(match, rawStart: string, rawEnd: string) => {
|
||||||
const start = Number(toAsciiDigits(rawStart));
|
const start = Number(normalizeAsciiDigits(rawStart));
|
||||||
const end = Number(toAsciiDigits(rawEnd));
|
const end = Number(normalizeAsciiDigits(rawEnd));
|
||||||
if (
|
if (
|
||||||
end <= start ||
|
end <= start ||
|
||||||
end - start > 20 ||
|
end - start > 20 ||
|
||||||
@@ -978,20 +993,23 @@ function extractStructuredToolPayload(content: unknown): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
|
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
|
||||||
if (
|
const directCandidate = findDisplayPayloadCandidate(value, 0);
|
||||||
Array.isArray(value.results) ||
|
if (directCandidate !== undefined) {
|
||||||
Array.isArray(value.data) ||
|
return directCandidate;
|
||||||
Array.isArray(value.items)
|
|
||||||
) {
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) {
|
for (const nested of Object.values(value)) {
|
||||||
const nested = value[key];
|
|
||||||
if (typeof nested === "string") {
|
if (typeof nested === "string") {
|
||||||
const parsed = parseJSONLikeText(nested);
|
const parsed = parseJSONLikeText(nested);
|
||||||
if (parsed !== undefined) {
|
if (parsed !== undefined) {
|
||||||
|
if (isRecord(parsed)) {
|
||||||
|
const structured = findDisplayPayloadCandidate(parsed, 0);
|
||||||
|
if (structured !== undefined) {
|
||||||
|
return structured;
|
||||||
|
}
|
||||||
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (isRecord(nested)) {
|
if (isRecord(nested)) {
|
||||||
const unwrapped = unwrapToolPayloadObject(nested);
|
const unwrapped = unwrapToolPayloadObject(nested);
|
||||||
@@ -1008,6 +1026,14 @@ function parseJSONLikeText(text: string): unknown {
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const fenced = extractFencedJSON(trimmed);
|
||||||
|
if (fenced) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fenced) as unknown;
|
||||||
|
} catch {
|
||||||
|
// Fall through to best-effort parsing.
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(trimmed) as unknown;
|
return JSON.parse(trimmed) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1016,17 +1042,9 @@ function parseJSONLikeText(text: string): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractJSONFromText(text: string): unknown {
|
function extractJSONFromText(text: string): unknown {
|
||||||
const candidates: Array<[number, number]> = [];
|
for (const candidate of iterateBalancedJsonCandidates(text)) {
|
||||||
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);
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(slice);
|
return JSON.parse(candidate);
|
||||||
} catch {
|
} catch {
|
||||||
// try the next candidate
|
// try the next candidate
|
||||||
}
|
}
|
||||||
@@ -1034,6 +1052,108 @@ function extractJSONFromText(text: string): unknown {
|
|||||||
return undefined;
|
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 {
|
function extractSearchPayloadFromText(text: string): unknown {
|
||||||
if (!text.includes('"results"') || !text.includes('"recUuid"')) {
|
if (!text.includes('"results"') || !text.includes('"recUuid"')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export async function getSkillDisplaySample(
|
|||||||
export interface InstallSkillRequest {
|
export interface InstallSkillRequest {
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
skill_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstallSkillResponse {
|
export interface InstallSkillResponse {
|
||||||
@@ -153,6 +154,27 @@ export interface InstallSkillResponse {
|
|||||||
overwritten?: boolean;
|
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 {
|
export interface ValidateSkillUploadResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
skill_name: string;
|
skill_name: string;
|
||||||
@@ -190,6 +212,22 @@ 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> {
|
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
const detail = (data as { detail?: unknown }).detail;
|
const detail = (data as { detail?: unknown }).detail;
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import logging
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import zipfile
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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")
|
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)")
|
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'")
|
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'")
|
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):
|
class SkillUploadValidateResponse(BaseModel):
|
||||||
"""Response model for the pre-install validation endpoint."""
|
"""Response model for the pre-install validation endpoint."""
|
||||||
|
|
||||||
@@ -1040,6 +1069,162 @@ async def install_skill_upload(
|
|||||||
tmp_path.unlink(missing_ok=True)
|
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(
|
@router.post(
|
||||||
"/skills/install",
|
"/skills/install",
|
||||||
response_model=SkillInstallResponse,
|
response_model=SkillInstallResponse,
|
||||||
@@ -1056,25 +1241,46 @@ async def install_skill(
|
|||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
is_admin = _is_admin_user(request)
|
is_admin = _is_admin_user(request)
|
||||||
target_category = body.target_category
|
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:
|
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")
|
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)
|
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:
|
try:
|
||||||
from deerflow.skills.usage import mark_uploaded
|
from deerflow.skills.usage import mark_uploaded
|
||||||
mark_uploaded(result["skill_name"])
|
mark_uploaded(result.skill_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if target_category == SkillCategory.PUBLIC:
|
if target_category == SkillCategory.PUBLIC:
|
||||||
await skill_store.ensure_legacy(
|
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:
|
else:
|
||||||
await skill_store.ensure_legacy(
|
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()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return SkillInstallResponse(**result)
|
return result
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except SkillAlreadyExistsError as e:
|
except SkillAlreadyExistsError as e:
|
||||||
|
|||||||
Reference in new issue
Block a user