ji
This commit is contained in:
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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
Reference in new issue
Block a user