技能展示适配持久化 + 参考文献收集与新对话流式修复

- 后端新增 skill_display_adapters 表,持久化每个技能的 display 配置与 sample
  payload;/api/skills 列表、详情、设置接口同步读写
- 新增 /api/skills/{name}/display-sample 端点,编辑器优先加载库中样例
- 前端展示适配面板改为从后端加载持久样例,无样例时提示"自动识别"
- 参考文献收集修复:仅在 bash/shell/python 等执行类工具上从参数推断技能
  名,避免 read_file / web_fetch 误匹配;引用列表纳入最新一条助手消息
- ChatBox 改用「最新 human → 最新 ai」窗口收集引用,并仅保留 citation
  模式
- 修复「新对话」点击后已发消息消失:useThreadStream 只在 threadId 切回
  空值时重置/中止 SSE,避免 onCreated 分配 id 时把刚开始的流打断;
  ChatPage 在 landing 状态隐藏 MessageList,移除 KeyedChatPage remount
- 技能详情对话框:所有者可查看源码与文件 tab(不再要求管理员)
- LocalSandbox:Windows PowerShell 5 路径下把 && 重写为 ;,并以
  UTF-8 解码 subprocess 输出,避免中文乱码
- knowledge-search-v2 mock 样例改为中文固定测试数据

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 authored and xuhd committed 2026-05-28 18:28:41 +08:00
1 parent 7adb93fb8e
commit 0256d527f0
16 files changed
+769 -181

No files matched your search

@@ -61,10 +61,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills], [settings.display.ragModeEnabled, skills],
); );
const latestAssistant = useMemo( const latestHumanIndex = useMemo(
() => findLatestAssistantMessage(thread.values.messages ?? []), () => findLatestHumanMessageIndex(thread.values.messages ?? []),
[thread.values.messages], [thread.values.messages],
); );
const latestAssistant = useMemo(
() => findLatestAssistantMessageAfter(thread.values.messages ?? [], latestHumanIndex),
[latestHumanIndex, thread.values.messages],
);
const displaySources = useMemo(() => { const displaySources = useMemo(() => {
if (!settings.display.ragModeEnabled) { if (!settings.display.ragModeEnabled) {
return []; return [];
@@ -74,10 +78,12 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
return []; return [];
} }
return collectRuntimeDisplaySources(messages, skillDisplayMap, { return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id), afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined,
beforeMessageId: latestAssistant.id, beforeMessageId: latestAssistant.id,
includeBeforeMessage: true,
includeModes: ["citation"],
}); });
}, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, thread.values.messages]); }, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, latestHumanIndex, thread.values.messages]);
const referenceSources = useMemo( const referenceSources = useMemo(
() => displaySources.filter((source) => source.mode === "citation"), () => displaySources.filter((source) => source.mode === "citation"),
[displaySources], [displaySources],
@@ -318,8 +324,20 @@ function ReferencePanel({
); );
} }
function findLatestAssistantMessage(messages: Message[]) { function findLatestHumanMessageIndex(messages: Message[]) {
for (let index = messages.length - 1; index >= 0; index--) { for (let index = messages.length - 1; index >= 0; index--) {
if (messages[index]?.type === "human") {
return index;
}
}
return -1;
}
function findLatestAssistantMessageAfter(messages: Message[], afterIndex: number) {
for (let index = messages.length - 1; index >= 0; index--) {
if (afterIndex >= 0 && index <= afterIndex) {
return null;
}
const message = messages[index]; const message = messages[index];
if (message?.type === "ai") { if (message?.type === "ai") {
return message; return message;
@@ -328,22 +346,4 @@ function findLatestAssistantMessage(messages: Message[]) {
return null; return null;
} }
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return undefined;
}
export { ChatBox }; export { ChatBox };
@@ -51,8 +51,11 @@ import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { import {
buildSkillDisplayMap, buildSkillDisplayMap,
findDisplayConfigForPayload,
findDisplayConfigForTool, findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources, normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource, type RuntimeDisplaySource,
} from "@/core/skill-display/runtime"; } from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter"; import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
@@ -249,7 +252,13 @@ function ToolCall({
const { t } = useI18n(); const { t } = useI18n();
const artifacts = useArtifactsOptional(); const artifacts = useArtifactsOptional();
const displayMatch = findDisplayConfigForTool(name, skillDisplayMap, args); const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
(result !== undefined
? findDisplayConfigForPayload(name, result, skillDisplayMap)
: null));
const displayItems = const displayItems =
displayMatch && result !== undefined displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
@@ -263,7 +272,11 @@ function ToolCall({
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
key={id} key={id}
label={displayMatch?.display.mode === "citation" || !displayMatch ? "检索参考来源" : `获取${displayItems.length}条结果`} label={
displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
}
icon={SearchIcon} icon={SearchIcon}
> >
<DisplayResultsList results={displayItems} /> <DisplayResultsList results={displayItems} />
@@ -272,7 +285,7 @@ function ToolCall({
} }
const searchItems = normalizeSearchResults(result); const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && searchItems.length > 0) { if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({ const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item, ...item,
index: index + 1, index: index + 1,
@@ -618,7 +631,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
open && "rotate-180", open && "rotate-180",
)} )}
/> />
<span>{results.length} 条适配结果</span> <span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2"> <CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => ( {results.map((item) => (
@@ -1,7 +1,8 @@
"use client"; "use client";
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react"; import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -18,10 +19,12 @@ import { Textarea } from "@/components/ui/textarea";
import { import {
emptyDisplayConfig, emptyDisplayConfig,
inferDisplayConfig, inferDisplayConfig,
mockKnowledgeSearchV2Result,
normalizeDisplaySources, normalizeDisplaySources,
} from "@/core/skill-display/display-adapter"; } from "@/core/skill-display/display-adapter";
import { getSkillDisplaySample } from "@/core/skills/api"; import {
getPersistedSkillDisplaySample,
getSkillDisplaySample,
} from "@/core/skills/api";
import { useSetSkillDisplay } from "@/core/skills/hooks"; import { useSetSkillDisplay } from "@/core/skills/hooks";
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type"; import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
@@ -62,10 +65,16 @@ function updateConfig<K extends keyof SkillDisplayConfig>(
return { ...config, [key]: value }; return { ...config, [key]: value };
} }
function formatSample(sample: Record<string, unknown> | null | undefined) {
return sample ? JSON.stringify(sample, null, 2) : "";
}
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) { export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const queryClient = useQueryClient();
const setDisplay = useSetSkillDisplay(); const setDisplay = useSetSkillDisplay();
const lastSkillNameRef = useRef(skill.name);
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig()); const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [sampleText, setSampleText] = useState(() => JSON.stringify(mockKnowledgeSearchV2Result, null, 2)); const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2)); const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
// Echo server state into the form — on mount, on skill switch, AND after // Echo server state into the form — on mount, on skill switch, AND after
@@ -83,8 +92,52 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
// per skill, otherwise the user loses the JSON they just pulled in via // per skill, otherwise the user loses the JSON they just pulled in via
// 自动识别 every time they hit 保存. // 自动识别 every time they hit 保存.
useEffect(() => { useEffect(() => {
setSampleText(JSON.stringify(mockKnowledgeSearchV2Result, null, 2)); if (lastSkillNameRef.current !== skill.name) {
}, [skill.name]); lastSkillNameRef.current = skill.name;
setSampleText(formatSample(skill.displaySample));
return;
}
if (skill.displaySample) {
setSampleText(formatSample(skill.displaySample));
}
}, [skill.name, skill.displaySample]);
useEffect(() => {
let cancelled = false;
async function loadPersistedSample() {
try {
const response = await getPersistedSkillDisplaySample(skill.name);
if (cancelled) {
return;
}
if (response.sample) {
setSampleText(formatSample(response.sample));
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
current ? { ...current, displaySample: response.sample } : current,
);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) =>
item.name === skill.name
? { ...item, displaySample: response.sample }
: item,
)
: current,
);
} else if (!skill.displaySample) {
setSampleText("");
}
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
}
void loadPersistedSample();
return () => {
cancelled = true;
};
}, [queryClient, skill.name, skill.displaySample]);
const samplePayload = useMemo(() => { const samplePayload = useMemo(() => {
try { try {
@@ -114,6 +167,18 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const response = await getSkillDisplaySample(skill.name); const response = await getSkillDisplaySample(skill.name);
const nextSampleText = JSON.stringify(response.sample, null, 2); const nextSampleText = JSON.stringify(response.sample, null, 2);
setSampleText(nextSampleText); setSampleText(nextSampleText);
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
current ? { ...current, displaySample: response.sample } : current,
);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) =>
item.name === skill.name
? { ...item, displaySample: response.sample }
: item,
)
: current,
);
const nextDraft = inferDisplayConfig(response.sample, skill.name); const nextDraft = inferDisplayConfig(response.sample, skill.name);
setDraft(nextDraft); setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2)); setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
@@ -126,7 +191,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
async function saveDraft() { async function saveDraft() {
try { try {
await setDisplay.mutateAsync({ skillName: skill.name, display: draft }); await setDisplay.mutateAsync({
skillName: skill.name,
display: draft,
sample: samplePayload ?? undefined,
});
toast.success("展示适配已保存"); toast.success("展示适配已保存");
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
@@ -135,7 +204,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
async function clearDisplay() { async function clearDisplay() {
try { try {
await setDisplay.mutateAsync({ skillName: skill.name, display: null }); await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
setDraft(emptyDisplayConfig()); setDraft(emptyDisplayConfig());
setTableColumnsText(JSON.stringify([], null, 2)); setTableColumnsText(JSON.stringify([], null, 2));
toast.success("展示适配已清除"); toast.success("展示适配已清除");
@@ -278,7 +347,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<div className="text-sm font-medium">展示预览</div> <div className="text-sm font-medium">展示预览</div>
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3"> <div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
{!samplePayload ? ( {!samplePayload ? (
<div className="text-sm text-destructive">样例 JSON 解析失败</div> sampleText.trim() ? (
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
) : (
<div className="text-sm text-muted-foreground">数据库暂无技能返回样例,请点击自动识别</div>
)
) : previewItems.length === 0 ? ( ) : previewItems.length === 0 ? (
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div> <div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
) : draft.mode === "citation" ? ( ) : draft.mode === "citation" ? (
@@ -68,6 +68,18 @@ function canManageSkill(skill: Skill | null | undefined): boolean {
); );
} }
function isSkillOwner(skill: Skill | null | undefined): boolean {
if (!skill) return false;
const auth = getStoredAuth();
if (!auth) return false;
return (
skill.category === "custom" &&
!skill.builtin &&
!!skill.owner_user_id &&
skill.owner_user_id === auth.user_id
);
}
function stripYamlFrontmatter(content: string): string { function stripYamlFrontmatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart(); return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
} }
@@ -96,13 +108,14 @@ export function SkillDetailDialog({
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null); const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
const canManage = useMemo(() => canManageSkill(skill), [skill]); const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
const { const {
data: contentData, data: contentData,
isLoading: isContentLoading, isLoading: isContentLoading,
error: contentError, error: contentError,
} = useSkillContent(open && canManage ? skillName : null); } = useSkillContent(open ? skillName : null);
const filesEnabled = open && canManage && activeTab === "files"; const filesEnabled = open && isOwner && activeTab === "files";
const { const {
data: filesData, data: filesData,
isLoading: isFilesLoading, isLoading: isFilesLoading,
@@ -121,7 +134,7 @@ export function SkillDetailDialog({
}, [contentData]); }, [contentData]);
const defaultTab: "detail" | "source" = const defaultTab: "detail" | "source" =
canManage && skill && !skill.detail ? "source" : "detail"; skill && !skill.detail ? "source" : "detail";
const selectedTab = activeTab ?? defaultTab; const selectedTab = activeTab ?? defaultTab;
function startEditing() { function startEditing() {
@@ -215,18 +228,16 @@ export function SkillDetailDialog({
展示适配 展示适配
</TabsTrigger> </TabsTrigger>
)} )}
{canManage && ( {isOwner && (
<TabsTrigger value="files" className="text-xs"> <TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" /> <FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle} {tx.filesSectionTitle}
</TabsTrigger> </TabsTrigger>
)} )}
{canManage && ( <TabsTrigger value="source" className="text-xs">
<TabsTrigger value="source" className="text-xs"> <FileCode2Icon className="mr-1 size-3.5" />
<FileCode2Icon className="mr-1 size-3.5" /> {tx.contentSectionTitle}
{tx.contentSectionTitle} </TabsTrigger>
</TabsTrigger>
)}
</TabsList> </TabsList>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
@@ -242,7 +253,6 @@ export function SkillDetailDialog({
</Button> </Button>
)} )}
{selectedTab === "source" && {selectedTab === "source" &&
canManage &&
!isContentLoading && !isContentLoading &&
!contentError && ( !contentError && (
<> <>
@@ -327,7 +337,7 @@ export function SkillDetailDialog({
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center"> <div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" /> <FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.detailEmpty}</p> <p className="text-sm">{tx.detailEmpty}</p>
{canManage ? ( {isOwner ? (
<p className="mt-1 text-xs"> <p className="mt-1 text-xs">
Open the SKILL.md tab to view the skill source. Open the SKILL.md tab to view the skill source.
</p> </p>
@@ -348,7 +358,7 @@ export function SkillDetailDialog({
</TabsContent> </TabsContent>
)} )}
{canManage && ( {isOwner && (
<TabsContent <TabsContent
value="files" value="files"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden" className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
@@ -376,12 +386,11 @@ export function SkillDetailDialog({
</TabsContent> </TabsContent>
)} )}
{canManage && ( <TabsContent
<TabsContent value="source"
value="source" className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden" style={{ overflowY: "auto" }}
style={{ overflowY: "auto" }} >
>
{isContentLoading ? ( {isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm"> <div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@@ -407,7 +416,6 @@ export function SkillDetailDialog({
/> />
)} )}
</TabsContent> </TabsContent>
)}
</Tabs> </Tabs>
)} )}
</DialogContent> </DialogContent>
@@ -27,50 +27,65 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
urlField: null, urlField: null,
idField: null, idField: null,
linkStrategy: "auto", linkStrategy: "auto",
linkTemplate: null, linkTemplate: null,
citationToken: "doubleBracket", citationToken: "doubleBracket",
maxItems: 8, maxItems: 8,
customInstruction: "", customInstruction: "",
tableColumns: [], tableColumns: [],
}); });
export const mockKnowledgeSearchV2Result = { export const mockKnowledgeSearchV2Result = {
success: true, success: true,
query: "latest news", query: "参考文献模式测试 知识库固定测试",
count: 3, count: 4,
source: "fixed-display-sample",
results: [ results: [
{ {
index: 1, index: 1,
recUuid: "rec-001", recUuid: "ksv2-fixed-001",
relevance_score: 0.982, relevance_score: 0.982,
title: "Knowledge result one", title: "参考文献模式测试:参考文献模式固定测试数据一",
source_category: "knowledge/news", source_category: "知识库/测试资料",
source: "internal news library", source: "knowledge-search-v2/fixed/source-001",
publish_time: "2026-05-25", publish_time: "2026-05-25",
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.", content_preview:
content_length: 1200, "这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
content_length: 168,
}, },
{ {
index: 2, index: 2,
recUuid: "rec-002", recUuid: "ksv2-fixed-002",
relevance_score: 0.951, relevance_score: 0.936,
title: "Knowledge result two", title: "参考文献模式测试:结构化字段映射验证",
source_category: "knowledge/news", source_category: "知识库/字段映射",
source: "internal news library", source: "knowledge-search-v2/fixed/source-002",
publish_time: "2026-05-24", publish_time: "2026-05-24",
content_preview: "Mock result used to verify citation ordering and detail preview.", content_preview:
content_length: 980, "本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
content_length: 154,
}, },
{ {
index: 3, index: 3,
recUuid: "rec-003", recUuid: "ksv2-fixed-003",
relevance_score: 0.921, relevance_score: 0.887,
title: "Knowledge result three", title: "参考文献模式测试:多引用排序验证",
source_category: "knowledge/commentary", source_category: "知识库/排序验证",
source: "internal knowledge library", source: "knowledge-search-v2/fixed/source-003",
publish_time: "2026-05-23", publish_time: "2026-05-23",
content_preview: "Mock result used to verify list mode and custom display instruction previews.", content_preview:
content_length: 760, "本条用于验证多条参考文献的编号顺序是否与 results 数组顺序一致,并验证点击正文编号时能定位到右侧对应参考文献。",
content_length: 132,
},
{
index: 4,
recUuid: "",
relevance_score: 0.801,
title: "参考文献模式测试:无 recUuid 跳转隐藏验证",
source_category: "知识库/边界场景",
source: "knowledge-search-v2/fixed/source-004",
publish_time: "2026-05-22",
content_preview: "本条故意把 recUuid 置为空,用于验证右侧弹框仍展示详情,但不展示外部跳转按钮。",
content_length: 98,
}, },
], ],
}; };
@@ -196,7 +211,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
.slice(0, config.maxItems) .slice(0, config.maxItems)
.map((item, index) => ({ .map((item, index) => ({
id: config.idField ? stringify(item[config.idField]) : undefined, id: config.idField ? stringify(item[config.idField]) : undefined,
title: config.titleField ? stringify(item[config.titleField], `Result ${index + 1}`) : `Result ${index + 1}`, title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
type: config.typeField ? stringify(item[config.typeField], "result") : "result", type: config.typeField ? stringify(item[config.typeField], "result") : "result",
snippet: config.summaryField ? stringify(item[config.summaryField]) : "", snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
content: config.contentField ? stringify(item[config.contentField]) : undefined, content: config.contentField ? stringify(item[config.contentField]) : undefined,
+244 -20
View File
@@ -56,7 +56,7 @@ export function findDisplayConfigForTool(
// Scan stringy args for the `skills/{public,custom}/<name>/` shape so display // Scan stringy args for the `skills/{public,custom}/<name>/` shape so display
// configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in // configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in
// tool_metrics_middleware.py. // tool_metrics_middleware.py.
if (toolArgs) { if (toolArgs && canInferSkillFromToolArgs(toolName)) {
const skillFromArgs = extractSkillNameFromArgs(toolArgs); const skillFromArgs = extractSkillNameFromArgs(toolArgs);
if (skillFromArgs) { if (skillFromArgs) {
const direct = displays.get(skillFromArgs); const direct = displays.get(skillFromArgs);
@@ -69,6 +69,51 @@ export function findDisplayConfigForTool(
} }
const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/; const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/;
const TOOL_ARG_SKILL_MATCH_EXCLUDES = new Set([
"read_file",
"readfile",
"ls",
"write_file",
"writefile",
"str_replace",
"strreplace",
"web_fetch",
"webfetch",
]);
function canInferSkillFromToolArgs(toolName: string): boolean {
const normalized = normalizeName(toolName);
if (
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) ||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized)
) {
return false;
}
return (
normalized.includes("bash") ||
normalized.includes("shell") ||
normalized.includes("python") ||
normalized.includes("execute") ||
normalized.includes("terminal")
);
}
function canInspectPayloadForDisplay(toolName: string): boolean {
const normalized = normalizeName(toolName);
if (
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) ||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized)
) {
return false;
}
return (
canInferSkillFromToolArgs(toolName) ||
normalized.includes("search") ||
normalized.includes("retriev") ||
normalized.includes("knowledge") ||
normalized.includes("query")
);
}
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined { function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
for (const value of Object.values(args)) { for (const value of Object.values(args)) {
@@ -87,6 +132,7 @@ export function collectRuntimeDisplaySources(
options: { options: {
afterMessageId?: string; afterMessageId?: string;
beforeMessageId?: string; beforeMessageId?: string;
includeBeforeMessage?: boolean;
includeModes?: SkillDisplayConfig["mode"][]; includeModes?: SkillDisplayConfig["mode"][];
} = {}, } = {},
): RuntimeDisplaySource[] { ): RuntimeDisplaySource[] {
@@ -101,7 +147,8 @@ export function collectRuntimeDisplaySources(
options.beforeMessageId === undefined options.beforeMessageId === undefined
? messages.length ? messages.length
: Math.max( : Math.max(
messages.findIndex((message) => message.id === options.beforeMessageId), messages.findIndex((message) => message.id === options.beforeMessageId) +
(options.includeBeforeMessage ? 1 : 0),
0, 0,
); );
const allowedModes = options.includeModes const allowedModes = options.includeModes
@@ -117,22 +164,38 @@ export function collectRuntimeDisplaySources(
continue; continue;
} }
for (const toolCall of message.tool_calls ?? []) { for (const toolCall of message.tool_calls ?? []) {
const match = findDisplayConfigForTool( const toolMatch = findDisplayConfigForTool(
toolCall.name, toolCall.name,
displays, displays,
toolCall.args as Record<string, unknown> | undefined, toolCall.args as Record<string, unknown> | undefined,
); );
const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) {
continue;
}
const result = findToolResultPayload(toolCall.id, messages); const result = findToolResultPayload(toolCall.id, messages);
if (result === undefined) { if (result === undefined) {
continue; continue;
} }
if (isDisplaySamplePayload(result)) {
continue;
}
const match =
toolMatch ??
findDisplayConfigForPayload(
toolCall.name,
result,
displays,
);
const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) {
continue;
}
const normalized = match const normalized = match
? normalizeDisplaySources(result, match.display) ? normalizeDisplaySources(result, match.display)
: normalizeDefaultDisplaySources(result); : shouldUseDefaultCitationMode(
toolCall.name,
result,
toolCall.args as Record<string, unknown> | undefined,
)
? normalizeDefaultDisplaySources(result)
: [];
for (const item of normalized) { for (const item of normalized) {
const skillName = match?.skillName ?? toolCall.name; const skillName = match?.skillName ?? toolCall.name;
const displayKey = `${skillName}:${effectiveMode}`; const displayKey = `${skillName}:${effectiveMode}`;
@@ -151,6 +214,113 @@ export function collectRuntimeDisplaySources(
return sources; return sources;
} }
export function findDisplayConfigForPayload(
toolName: string,
payload: unknown,
displays: SkillDisplayMap,
): { skillName: string; display: SkillDisplayConfig } | null {
if (!canInspectPayloadForDisplay(toolName)) {
return null;
}
let best: { skillName: string; display: SkillDisplayConfig; score: number } | null = null;
for (const [skillName, display] of displays) {
const score = scoreDisplayPayloadMatch(payload, display);
if (score >= 3 && (!best || score > best.score)) {
best = { skillName, display, score };
}
}
return best ? { skillName: best.skillName, display: best.display } : null;
}
export function isDisplaySamplePayload(payload: unknown): boolean {
if (!isRecord(payload)) {
return false;
}
if (payload.__displaySample === true || payload.displaySample === true) {
return true;
}
if (payload.query === "sample query") {
return true;
}
const items =
getArrayByPath(payload, "results") ??
getArrayByPath(payload, "data") ??
getArrayByPath(payload, "items");
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
if (!first) {
return false;
}
const text = Object.values(first)
.filter((value) => typeof value === "string")
.join("\n")
.toLowerCase();
return (
/^sample-/i.test(String(first.id ?? "")) ||
text.includes("mock result") ||
text.includes("knowledge result one")
);
}
function scoreDisplayPayloadMatch(payload: unknown, display: SkillDisplayConfig): number {
const items = getArrayByPath(payload, display.resultPath);
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
if (!first) {
return 0;
}
const fields = [
display.titleField,
display.summaryField,
display.contentField,
display.sourceField,
display.typeField,
display.timeField,
display.scoreField,
display.urlField,
display.idField,
].filter((field): field is string => !!field);
return new Set(fields).size === 0
? 0
: [...new Set(fields)].filter(
(field) =>
first[field] !== undefined &&
first[field] !== null &&
first[field] !== "",
).length;
}
function getArrayByPath(payload: unknown, path: string): unknown[] | null {
const value = path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, payload);
return Array.isArray(value) ? value : null;
}
export function shouldUseDefaultCitationMode(
toolName: string,
payload: unknown,
toolArgs?: Record<string, unknown>,
): boolean {
const normalizedName = normalizeName(toolName);
const isSearchLikeTool =
normalizedName.includes("search") ||
normalizedName.includes("retriev") ||
normalizedName.includes("knowledge") ||
normalizedName.includes("query");
const isSkillInvocation = !!(
toolArgs &&
canInferSkillFromToolArgs(toolName) &&
extractSkillNameFromArgs(toolArgs)
);
if (!isSearchLikeTool && !isSkillInvocation) {
return false;
}
return normalizeDefaultDisplaySources(payload).length > 0;
}
export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] { export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] {
const normalized = normalizeSearchResults( const normalized = normalizeSearchResults(
typeof payload === "string" || isRecord(payload) typeof payload === "string" || isRecord(payload)
@@ -288,23 +458,77 @@ function findToolResultPayload(
if (!toolMessage) { if (!toolMessage) {
return undefined; return undefined;
} }
const structured = extractStructuredToolPayload(toolMessage.content);
if (structured !== undefined) {
return structured;
}
const content = extractToolText(toolMessage.content); const content = extractToolText(toolMessage.content);
if (!content) { if (!content) {
return undefined; return undefined;
} }
try { const parsed = parseJSONLikeText(content);
return JSON.parse(content) as unknown; if (parsed !== undefined) {
} catch { return parsed;
// bash stdout on the local sandbox can be suffixed with }
// "\nStd Error: ..." / "\nExit Code: N" (see local_sandbox.execute_command), return content;
// and skills sometimes print informational lines around their JSON payload. }
// Salvage the first balanced JSON object or array we can find before
// falling back to the raw string. function extractStructuredToolPayload(content: unknown): unknown {
const recovered = extractJSONFromText(content); if (isRecord(content)) {
if (recovered !== undefined) { return unwrapToolPayloadObject(content);
return recovered; }
if (Array.isArray(content)) {
for (const part of content) {
if (!isRecord(part)) {
continue;
}
if (isRecord(part.json)) {
return unwrapToolPayloadObject(part.json);
}
const unwrapped = unwrapToolPayloadObject(part);
if (unwrapped !== undefined) {
return unwrapped;
}
} }
return content; }
return undefined;
}
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
if (
Array.isArray(value.results) ||
Array.isArray(value.data) ||
Array.isArray(value.items)
) {
return value;
}
for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) {
const nested = value[key];
if (typeof nested === "string") {
const parsed = parseJSONLikeText(nested);
if (parsed !== undefined) {
return parsed;
}
}
if (isRecord(nested)) {
const unwrapped = unwrapToolPayloadObject(nested);
if (unwrapped !== undefined) {
return unwrapped;
}
}
}
return undefined;
}
function parseJSONLikeText(text: string): unknown {
const trimmed = text.trim();
if (!trimmed) {
return undefined;
}
try {
return JSON.parse(trimmed) as unknown;
} catch {
return extractJSONFromText(trimmed);
} }
} }
+19 -1
View File
@@ -49,6 +49,7 @@ export async function enableSkill(skillName: string, enabled: boolean) {
export async function setSkillDisplay( export async function setSkillDisplay(
skillName: string, skillName: string,
display: SkillDisplayConfig | null, display: SkillDisplayConfig | null,
sample?: Record<string, unknown> | null,
): Promise<Skill> { ): Promise<Skill> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`, `${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
@@ -57,7 +58,7 @@ export async function setSkillDisplay(
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ display }), body: JSON.stringify({ display, sample }),
}, },
); );
if (!response.ok) { if (!response.ok) {
@@ -71,6 +72,23 @@ export interface SkillDisplaySampleResponse {
sample: Record<string, unknown>; sample: Record<string, unknown>;
} }
export interface PersistedSkillDisplaySampleResponse {
skill_name: string;
sample: Record<string, unknown> | null;
}
export async function getPersistedSkillDisplaySample(
skillName: string,
): Promise<PersistedSkillDisplaySampleResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
}
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
}
export async function getSkillDisplaySample( export async function getSkillDisplaySample(
skillName: string, skillName: string,
): Promise<SkillDisplaySampleResponse> { ): Promise<SkillDisplaySampleResponse> {
+3 -1
View File
@@ -71,10 +71,12 @@ export function useSetSkillDisplay() {
mutationFn: ({ mutationFn: ({
skillName, skillName,
display, display,
sample,
}: { }: {
skillName: string; skillName: string;
display: Parameters<typeof setSkillDisplay>[1]; display: Parameters<typeof setSkillDisplay>[1];
}) => setSkillDisplay(skillName, display), sample?: Parameters<typeof setSkillDisplay>[2];
}) => setSkillDisplay(skillName, display, sample),
onSuccess: (_data, { skillName }) => { onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] }); void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] }); void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
+1
View File
@@ -48,6 +48,7 @@ export interface Skill {
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown. // Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null; detail?: string | null;
display?: SkillDisplayConfig | null; display?: SkillDisplayConfig | null;
displaySample?: Record<string, unknown> | null;
// Lifecycle fields — populated by curator. The main list filters on these // Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead. // so stale / archived skills appear in dedicated drawers instead.
state?: SkillState; state?: SkillState;
+16 -6
View File
@@ -315,13 +315,23 @@ export function useThreadStream({
const threadStreamRef = useRef(thread); const threadStreamRef = useRef(thread);
threadStreamRef.current = thread; threadStreamRef.current = thread;
// Reset thread-local pending UI state when switching between threads so // Reset thread-local pending UI state when the caller actually switches AWAY
// optimistic messages and in-flight guards do not leak across chat views. // from a thread (threadId becomes nullish). This is the "click 新对话 / open
// Also abort any in-flight SSE from the previous thread: without this, // a fresh chat" signal — abort the in-flight SSE and clear optimistic state.
// clicking "新对话" mid-stream leaves the old run firing onUpdate events //
// against the now-detached thread context, which manifests as the page // We deliberately do NOT reset on every threadId change. During a normal
// appearing unresponsive to the navigation. // "user just sent the first message on /chats/new" flow, useThreadStream
// sees threadId transition from undefined (the landing state) to the
// server-assigned thread id once onCreated fires. Stopping the stream and
// clearing optimistic messages in that case is exactly what made submitted
// questions visually disappear — the live run that just started would be
// aborted by the very effect intended to reset stale state.
useEffect(() => { useEffect(() => {
if (threadId != null) {
// Forward direction: starting or continuing with a real thread.
// No reset needed; the upstream useStream will load history for this id.
return;
}
const prevThread = threadStreamRef.current; const prevThread = threadStreamRef.current;
if (prevThread?.isLoading) { if (prevThread?.isLoading) {
try { try {
+19 -8
View File
@@ -231,14 +231,25 @@ export default function ChatPage() {
</header> </header>
<main className="flex min-h-0 max-w-full grow flex-col"> <main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center"> <div className="flex size-full justify-center">
<MessageList {/*
className={cn("size-full", !isLandingState && "pt-10")} Landing state covers two cases: (a) a brand-new chat that
threadId={threadId} hasn't sent anything yet, and (b) the user clicking 「新对话」
thread={thread} mid-stream. In (b) the underlying useStream's internal
paddingBottom={messageListPaddingBottom} stream.values takes one or two effect ticks to clear, so
tokenUsageEnabled={tokenUsageEnabled} rendering MessageList unconditionally would leak the previous
onClarificationSubmit={handleClarificationSubmit} conversation under the welcome card. Skip rendering during
/> the landing state to guarantee a clean slate.
*/}
{!isLandingState && (
<MessageList
className={cn("size-full", "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
)}
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> <div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div <div
+4 -16
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
@@ -89,20 +89,6 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
); );
} }
/**
* 让 ChatPage 在每次真正的 react-router navigation 上完整 remount。
*
* 背景:点「新对话」时即使路径不变,我们也会用 `navigate(..., { replace: true,
* state: { resetAt } })` 强制刷新 location.key —— 用它作 key 能保证 useStream、
* useThreadStream、optimistic state、refs 一次性全部丢弃,避免上一条流的
* `stream.values` 残留把欢迎页底下的旧消息拖出来(SDK 的 stream.clear() 需要
* 多次 effect tick 才会清干净,中间过渡帧用户能看见)。
*/
function KeyedChatPage({ render }: { render: () => ReactNode }) {
const key = useLocation().key;
return <ChatRuntime key={key}>{render()}</ChatRuntime>;
}
export default function WorkspaceRoutes({ export default function WorkspaceRoutes({
pageSidebarCollapsed = false, pageSidebarCollapsed = false,
pageSidebarHidden = false, pageSidebarHidden = false,
@@ -137,7 +123,9 @@ export default function WorkspaceRoutes({
path="chats/:thread_id" path="chats/:thread_id"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<KeyedChatPage render={() => <ChatPage />} /> <ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
@@ -11,13 +11,16 @@ from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
from app.gateway.path_utils import resolve_thread_virtual_path from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
from deerflow.persistence.engine import get_engine, get_session_factory
from deerflow.persistence.skills import SkillStore from deerflow.persistence.skills import SkillStore
from deerflow.persistence.skills.model import SkillDisplayAdapterRow
from deerflow.persistence.tags import TagStore from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills import Skill from deerflow.skills import Skill
@@ -120,6 +123,7 @@ class SkillResponse(BaseModel):
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)") builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin") detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter") display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
display_sample: dict[str, Any] | None = Field(default=None, alias="displaySample", description="Persisted sample output used by the display adapter editor")
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill") tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
@@ -139,6 +143,7 @@ class SkillDisplayUpdateRequest(BaseModel):
"""Request model for updating a skill's frontend display adapter.""" """Request model for updating a skill's frontend display adapter."""
display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it") display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it")
sample: dict[str, Any] | None = Field(default=None, description="Optional sample payload to persist for future editor sessions")
class SkillDisplaySampleResponse(BaseModel): class SkillDisplaySampleResponse(BaseModel):
@@ -148,6 +153,13 @@ class SkillDisplaySampleResponse(BaseModel):
sample: dict[str, Any] sample: dict[str, Any]
class PersistedSkillDisplaySampleResponse(BaseModel):
"""Persisted sample output loaded from the database only."""
skill_name: str
sample: dict[str, Any] | None = None
class SkillInstallRequest(BaseModel): class SkillInstallRequest(BaseModel):
"""Request model for installing a skill from a .skill file.""" """Request model for installing a skill from a .skill file."""
@@ -202,7 +214,13 @@ class SkillRollbackRequest(BaseModel):
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.") history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) -> SkillResponse: def _skill_to_response(
skill: Skill,
ownership: dict[str, Any] | None = None,
*,
display_override: dict[str, Any] | None = None,
display_sample: dict[str, Any] | None = None,
) -> SkillResponse:
"""Convert a Skill object to a SkillResponse. """Convert a Skill object to a SkillResponse.
``ownership`` is the skill's record from ``SkillStore`` (or None for public ``ownership`` is the skill's record from ``SkillStore`` (or None for public
@@ -231,13 +249,14 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
builtin = bool(ownership.get("builtin", owner_user_id is None)) builtin = bool(ownership.get("builtin", owner_user_id is None))
detail = ownership.get("detail") detail = ownership.get("detail")
display = None display = display_override
try: if display is None:
skill_config = get_extensions_config().skills.get(skill.name) try:
if skill_config and skill_config.display: skill_config = get_extensions_config().skills.get(skill.name)
display = skill_config.display.model_dump(by_alias=True) if skill_config and skill_config.display:
except Exception: display = skill_config.display.model_dump(by_alias=True)
logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True) except Exception:
logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True)
return SkillResponse( return SkillResponse(
name=skill.name, name=skill.name,
@@ -257,6 +276,7 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
builtin=builtin, builtin=builtin,
detail=detail, detail=detail,
display=display, display=display,
displaySample=display_sample,
) )
@@ -267,6 +287,93 @@ def _dump_extensions_config(config_obj: ExtensionsConfig) -> dict[str, Any]:
} }
async def _ensure_skill_display_table() -> bool:
"""Create the display adapter table when DB persistence is enabled."""
engine = get_engine()
sf = get_session_factory()
if engine is None or sf is None:
return False
async with engine.begin() as conn:
await conn.run_sync(SkillDisplayAdapterRow.__table__.create, checkfirst=True)
return True
def _json_text_loads(value: str | None) -> dict[str, Any] | None:
if not value:
return None
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else None
except Exception:
logger.warning("Failed to parse persisted skill display JSON", exc_info=True)
return None
def _json_text_dumps(value: dict[str, Any] | None) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
async def _get_skill_display_adapter(skill_name: str) -> dict[str, Any] | None:
if not await _ensure_skill_display_table():
return None
sf = get_session_factory()
if sf is None:
return None
async with sf() as session:
row = await session.get(SkillDisplayAdapterRow, skill_name)
if row is None:
return None
return {
"display": _json_text_loads(row.display_config),
"sample": _json_text_loads(row.sample_payload),
}
async def _list_skill_display_adapters(skill_names: list[str]) -> dict[str, dict[str, Any]]:
if not skill_names or not await _ensure_skill_display_table():
return {}
sf = get_session_factory()
if sf is None:
return {}
async with sf() as session:
result = await session.execute(
select(SkillDisplayAdapterRow).where(SkillDisplayAdapterRow.skill_name.in_(skill_names))
)
return {
row.skill_name: {
"display": _json_text_loads(row.display_config),
"sample": _json_text_loads(row.sample_payload),
}
for row in result.scalars()
}
async def _upsert_skill_display_adapter(
skill_name: str,
*,
display: dict[str, Any] | None | object = ...,
sample: dict[str, Any] | None | object = ...,
) -> None:
if not await _ensure_skill_display_table():
return
sf = get_session_factory()
if sf is None:
return
async with sf() as session:
row = await session.get(SkillDisplayAdapterRow, skill_name)
if row is None:
row = SkillDisplayAdapterRow(skill_name=skill_name)
session.add(row)
if display is not ...:
row.display_config = _json_text_dumps(display) # type: ignore[arg-type]
if sample is not ...:
row.sample_payload = _json_text_dumps(sample) # type: ignore[arg-type]
row.updated_at = datetime.now()
await session.commit()
_SKILL_SAMPLE_SYSTEM_PROMPT = ( _SKILL_SAMPLE_SYSTEM_PROMPT = (
"You infer realistic JSON return shapes for AI agent skills. " "You infer realistic JSON return shapes for AI agent skills. "
"Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that " "Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that "
@@ -361,41 +468,53 @@ def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]:
if skill_name == "knowledge-search-v2": if skill_name == "knowledge-search-v2":
return { return {
"success": True, "success": True,
"query": "latest news", "query": "参考文献模式测试 知识库固定测试",
"count": 3, "count": 4,
"source": "fixed-display-sample",
"results": [ "results": [
{ {
"index": 1, "index": 1,
"recUuid": "rec-001", "recUuid": "ksv2-fixed-001",
"relevance_score": 0.982, "relevance_score": 0.982,
"title": "Knowledge result one", "title": "参考文献模式测试:参考文献模式固定测试数据一",
"source_category": "knowledge/news", "source_category": "知识库/测试资料",
"source": "internal news library", "source": "knowledge-search-v2/fixed/source-001",
"publish_time": "2026-05-25", "publish_time": "2026-05-25",
"content_preview": "Mock result used to verify citation numbers, source cards, and recUuid template links.", "content_preview": "这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
"content_length": 1200, "content_length": 168,
}, },
{ {
"index": 2, "index": 2,
"recUuid": "rec-002", "recUuid": "ksv2-fixed-002",
"relevance_score": 0.951, "relevance_score": 0.936,
"title": "Knowledge result two", "title": "参考文献模式测试:结构化字段映射验证",
"source_category": "knowledge/news", "source_category": "知识库/字段映射",
"source": "internal news library", "source": "knowledge-search-v2/fixed/source-002",
"publish_time": "2026-05-24", "publish_time": "2026-05-24",
"content_preview": "Mock result used to verify citation ordering and detail preview.", "content_preview": "本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
"content_length": 980, "content_length": 154,
}, },
{ {
"index": 3, "index": 3,
"recUuid": "rec-003", "recUuid": "ksv2-fixed-003",
"relevance_score": 0.921, "relevance_score": 0.887,
"title": "Knowledge result three", "title": "参考文献模式测试:多引用排序验证",
"source_category": "knowledge/commentary", "source_category": "知识库/排序验证",
"source": "internal knowledge library", "source": "knowledge-search-v2/fixed/source-003",
"publish_time": "2026-05-23", "publish_time": "2026-05-23",
"content_preview": "Mock result used to verify list mode and custom display instruction previews.", "content_preview": "本条用于验证多条参考文献的编号顺序是否与 results 数组顺序一致,并验证点击正文编号时能定位到右侧对应参考文献。",
"content_length": 760, "content_length": 132,
},
{
"index": 4,
"recUuid": "",
"relevance_score": 0.801,
"title": "参考文献模式测试:无 recUuid 跳转隐藏验证",
"source_category": "知识库/边界场景",
"source": "knowledge-search-v2/fixed/source-004",
"publish_time": "2026-05-22",
"content_preview": "本条故意把 recUuid 置为空,用于验证右侧弹框仍展示详情,但不展示外部跳转按钮。",
"content_length": 98,
}, },
], ],
} }
@@ -457,19 +576,35 @@ async def list_skills(
user_id = _current_user_id(request) user_id = _current_user_id(request)
all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
ownership = await _build_ownership_index(skill_store, user_id) ownership = await _build_ownership_index(skill_store, user_id)
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
responses: list[SkillResponse] = [] responses: list[SkillResponse] = []
for skill in all_skills: for skill in all_skills:
adapter = display_adapters.get(skill.name, {})
# Public/built-in skills (no DB row) are visible to everyone. # Public/built-in skills (no DB row) are visible to everyone.
# Custom skills must have an ownership record in the visible set. # Custom skills must have an ownership record in the visible set.
if skill.category != SkillCategory.CUSTOM: if skill.category != SkillCategory.CUSTOM:
# Built-in skills usually have no DB row, but an admin-authored # Built-in skills usually have no DB row, but an admin-authored
# ``detail`` annotation materializes one — surface it when present. # ``detail`` annotation materializes one — surface it when present.
responses.append(_skill_to_response(skill, ownership.get(skill.name))) responses.append(
_skill_to_response(
skill,
ownership.get(skill.name),
display_override=adapter.get("display"),
display_sample=adapter.get("sample"),
)
)
continue continue
record = ownership.get(skill.name) record = ownership.get(skill.name)
if record is None: if record is None:
continue continue
responses.append(_skill_to_response(skill, record)) responses.append(
_skill_to_response(
skill,
record,
display_override=adapter.get("display"),
display_sample=adapter.get("sample"),
)
)
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids) responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
return SkillsListResponse(skills=responses) return SkillsListResponse(skills=responses)
except Exception as e: except Exception as e:
@@ -729,8 +864,14 @@ async def list_custom_skills(
user_id = _current_user_id(request) user_id = _current_user_id(request)
custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM] custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
ownership = await _build_ownership_index(skill_store, user_id) ownership = await _build_ownership_index(skill_store, user_id)
display_adapters = await _list_skill_display_adapters([skill.name for skill in custom])
responses = [ responses = [
_skill_to_response(skill, ownership[skill.name]) _skill_to_response(
skill,
ownership[skill.name],
display_override=display_adapters.get(skill.name, {}).get("display"),
display_sample=display_adapters.get(skill.name, {}).get("sample"),
)
for skill in custom for skill in custom
if skill.name in ownership if skill.name in ownership
] ]
@@ -1125,7 +1266,7 @@ class SkillContentResponse(BaseModel):
"/skills/{skill_name}/content", "/skills/{skill_name}/content",
response_model=SkillContentResponse, response_model=SkillContentResponse,
summary="Get Skill Markdown Content", summary="Get Skill Markdown Content",
description="Retrieve the raw SKILL.md content of a skill. Only an admin or the skill's publisher (owner of a custom skill) may read it; other users see only the description and detail.", description="Retrieve the raw SKILL.md content of a skill. Readable by any user the skill is visible to (built-in, published, or owned).",
) )
async def get_skill_content( async def get_skill_content(
skill_name: str, skill_name: str,
@@ -1139,13 +1280,16 @@ async def get_skill_content(
skill = next((s for s in skills if s.name == skill_name), None) skill = next((s for s in skills if s.name == skill_name), None)
if skill is None: if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
# The raw SKILL.md is implementation detail — only an admin or the user_id = _current_user_id(request)
# publisher (owner of a custom skill) may view it. Everyone else is # SKILL.md describes how to use a skill — visible to anyone who can see
# limited to the description + detail surfaced by the list/detail APIs. # the skill itself (built-in, published, or owned). Admins always pass.
record = await skill_store.get_any(skill_name) if not _is_admin_user(request):
owner_user_id = record.get("owner_user_id") if record else None record = await skill_store.get_any(skill_name)
if not _can_view_skill_source(skill.category, owner_user_id, _current_user_id(request), is_admin=_is_admin_user(request)): if skill.category == SkillCategory.CUSTOM:
raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can view the SKILL.md source") if record is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
if not record.get("published") and record.get("owner_user_id") != user_id:
raise HTTPException(status_code=403, detail="You can only view SKILL.md for skills visible to you")
content = skill.skill_file.read_text(encoding="utf-8") content = skill.skill_file.read_text(encoding="utf-8")
return SkillContentResponse(name=skill.name, category=skill.category, content=content) return SkillContentResponse(name=skill.name, category=skill.category, content=content)
except HTTPException: except HTTPException:
@@ -1664,6 +1808,14 @@ async def update_skill_display(
display=request.display, display=request.display,
) )
display_payload = request.display.model_dump(by_alias=True) if request.display is not None else None
sample_payload = request.sample if "sample" in request.model_fields_set else ...
await _upsert_skill_display_adapter(
skill_name,
display=display_payload,
sample=sample_payload,
)
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False) json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False)
@@ -1673,7 +1825,12 @@ async def update_skill_display(
updated_skill = next((s for s in skills if s.name == skill_name), None) updated_skill = next((s for s in skills if s.name == skill_name), None)
if updated_skill is None: if updated_skill is None:
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update") raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update")
return _skill_to_response(updated_skill) adapter = await _get_skill_display_adapter(skill_name) or {}
return _skill_to_response(
updated_skill,
display_override=adapter.get("display"),
display_sample=adapter.get("sample"),
)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -1681,11 +1838,40 @@ async def update_skill_display(
raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}")
@router.get(
"/skills/{skill_name}/display-sample",
response_model=PersistedSkillDisplaySampleResponse,
summary="Get Persisted Skill Display Sample",
description="Return only the sample stored in the database. Does not generate a fallback sample.",
)
async def get_persisted_skill_display_sample(
skill_name: str,
config: AppConfig = Depends(get_config),
) -> PersistedSkillDisplaySampleResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
adapter = await _get_skill_display_adapter(skill_name)
sample = adapter.get("sample") if adapter else None
return PersistedSkillDisplaySampleResponse(
skill_name=skill_name,
sample=sample if isinstance(sample, dict) else None,
)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get persisted display sample for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get persisted display sample: {str(e)}")
@router.post( @router.post(
"/skills/{skill_name}/display-sample", "/skills/{skill_name}/display-sample",
response_model=SkillDisplaySampleResponse, response_model=SkillDisplaySampleResponse,
summary="Get Skill Display Sample", summary="Get Skill Display Sample",
description="Return a structured sample response used by the frontend to infer display mapping.", description="Generate or return a structured sample response used by the frontend to infer display mapping.",
) )
async def get_skill_display_sample( async def get_skill_display_sample(
skill_name: str, skill_name: str,
@@ -1697,7 +1883,11 @@ async def get_skill_display_sample(
skill = next((s for s in skills if s.name == skill_name), None) skill = next((s for s in skills if s.name == skill_name), None)
if skill is None: if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
adapter = await _get_skill_display_adapter(skill_name)
if adapter and isinstance(adapter.get("sample"), dict):
return SkillDisplaySampleResponse(skill_name=skill_name, sample=adapter["sample"])
sample = await _generate_skill_display_sample(skill, config) sample = await _generate_skill_display_sample(skill, config)
await _upsert_skill_display_adapter(skill_name, sample=sample)
return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample) return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample)
except HTTPException: except HTTPException:
raise raise
@@ -30,7 +30,7 @@ from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskSubscriptionRow, ScheduledTaskSubscriptionRow,
SchedulerThreadRow, SchedulerThreadRow,
) )
from deerflow.persistence.skills.model import SkillRow from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_shares.model import ThreadShareRow from deerflow.persistence.thread_shares.model import ThreadShareRow
@@ -51,6 +51,7 @@ __all__ = [
"ScheduledTaskSubscriptionRow", "ScheduledTaskSubscriptionRow",
"SchedulerThreadRow", "SchedulerThreadRow",
"SkillRow", "SkillRow",
"SkillDisplayAdapterRow",
"TagAssignmentRow", "TagAssignmentRow",
"TagRow", "TagRow",
"ThreadMetaRow", "ThreadMetaRow",
@@ -31,3 +31,16 @@ class SkillRow(Base):
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class SkillDisplayAdapterRow(Base):
__tablename__ = "skill_display_adapters"
skill_name: Mapped[str] = mapped_column(String(64), primary_key=True)
# Store JSON as text for maximum MySQL compatibility. Some deployments still
# run MySQL variants where native JSON columns or unquoted names such as
# ``display`` are not accepted consistently.
display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
sample_payload: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -42,6 +42,18 @@ class LocalSandbox(Sandbox):
"""Return whether the selected shell is cmd.exe.""" """Return whether the selected shell is cmd.exe."""
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"} return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
@staticmethod
def _normalize_powershell_command(command: str) -> str:
"""Make bash-style command glue usable in Windows PowerShell 5.
The public tool is still named ``bash`` for model compatibility, but on
Windows local sandbox it may fall back to PowerShell. PowerShell 5 does
not support ``&&``, while the sandbox layer prepends commands with
``cd <workspace> &&``. Rewriting the separator keeps skill scripts
executable in that fallback shell.
"""
return command.replace("&&", ";")
@staticmethod @staticmethod
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None: def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
"""Return the first executable shell path or command found from candidates.""" """Return the first executable shell path or command found from candidates."""
@@ -304,7 +316,12 @@ class LocalSandbox(Sandbox):
if os.name == "nt": if os.name == "nt":
if self._is_powershell(shell): if self._is_powershell(shell):
args = [shell, "-NoProfile", "-Command", resolved_command] args = [
shell,
"-NoProfile",
"-Command",
self._normalize_powershell_command(resolved_command),
]
elif self._is_cmd_shell(shell): elif self._is_cmd_shell(shell):
args = [shell, "/c", resolved_command] args = [shell, "/c", resolved_command]
else: else:
@@ -315,6 +332,8 @@ class LocalSandbox(Sandbox):
shell=False, shell=False,
capture_output=True, capture_output=True,
text=True, text=True,
encoding="utf-8",
errors="replace",
timeout=600, timeout=600,
) )
else: else:
@@ -324,6 +343,8 @@ class LocalSandbox(Sandbox):
shell=False, shell=False,
capture_output=True, capture_output=True,
text=True, text=True,
encoding="utf-8",
errors="replace",
timeout=600, timeout=600,
) )
output = result.stdout output = result.stdout