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(() => ({}));