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

- 后端新增 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 committed 2026-05-25 23:16:51 +08:00
1 parent c4277f8566
commit ad5d55220c
17 files changed
+2723 -2134

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, skills],
);
const latestAssistant = useMemo(
() => findLatestAssistantMessage(thread.values.messages ?? []),
const latestHumanIndex = useMemo(
() => findLatestHumanMessageIndex(thread.values.messages ?? []),
[thread.values.messages],
);
const latestAssistant = useMemo(
() => findLatestAssistantMessageAfter(thread.values.messages ?? [], latestHumanIndex),
[latestHumanIndex, thread.values.messages],
);
const displaySources = useMemo(() => {
if (!settings.display.ragModeEnabled) {
return [];
@@ -74,10 +78,12 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
return [];
}
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id),
afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined,
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(
() => displaySources.filter((source) => source.mode === "citation"),
[displaySources],
@@ -318,8 +324,20 @@ function ReferencePanel({
);
}
function findLatestAssistantMessage(messages: Message[]) {
function findLatestHumanMessageIndex(messages: Message[]) {
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];
if (message?.type === "ai") {
return message;
@@ -328,22 +346,4 @@ function findLatestAssistantMessage(messages: Message[]) {
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 };
@@ -50,11 +50,14 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
findDisplayConfigForTool,
normalizeDefaultDisplaySources,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
buildSkillDisplayMap,
findDisplayConfigForPayload,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
@@ -249,7 +252,13 @@ function ToolCall({
const { t } = useI18n();
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 =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
@@ -263,7 +272,11 @@ function ToolCall({
return (
<ChainOfThoughtStep
key={id}
label={displayMatch?.display.mode === "citation" || !displayMatch ? "检索参考来源" : `获取${displayItems.length}条结果`}
label={
displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
}
icon={SearchIcon}
>
<DisplayResultsList results={displayItems} />
@@ -272,7 +285,7 @@ function ToolCall({
}
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) => ({
...item,
index: index + 1,
@@ -618,7 +631,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
open && "rotate-180",
)}
/>
<span>{results.length} 条适配结果</span>
<span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => (
@@ -204,6 +204,7 @@ export function MessageList({
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return (
@@ -1,7 +1,8 @@
"use client";
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 { Button } from "@/components/ui/button";
@@ -18,10 +19,12 @@ import { Textarea } from "@/components/ui/textarea";
import {
emptyDisplayConfig,
inferDisplayConfig,
mockKnowledgeSearchV2Result,
normalizeDisplaySources,
} 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 type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
@@ -62,10 +65,16 @@ function updateConfig<K extends keyof SkillDisplayConfig>(
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 }) {
const queryClient = useQueryClient();
const setDisplay = useSetSkillDisplay();
const lastSkillNameRef = useRef(skill.name);
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));
// 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
// 自动识别 every time they hit 保存.
useEffect(() => {
setSampleText(JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
}, [skill.name]);
if (lastSkillNameRef.current !== 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(() => {
try {
@@ -114,6 +167,18 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const response = await getSkillDisplaySample(skill.name);
const nextSampleText = JSON.stringify(response.sample, null, 2);
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);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
@@ -126,7 +191,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
async function saveDraft() {
try {
await setDisplay.mutateAsync({ skillName: skill.name, display: draft });
await setDisplay.mutateAsync({
skillName: skill.name,
display: draft,
sample: samplePayload ?? undefined,
});
toast.success("展示适配已保存");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
@@ -135,7 +204,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
async function clearDisplay() {
try {
await setDisplay.mutateAsync({ skillName: skill.name, display: null });
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
setDraft(emptyDisplayConfig());
setTableColumnsText(JSON.stringify([], null, 2));
toast.success("展示适配已清除");
@@ -278,7 +347,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<div className="text-sm font-medium">展示预览</div>
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
{!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 ? (
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
) : 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 {
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 canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(open && canManage ? skillName : null);
} = useSkillContent(open ? skillName : null);
const filesEnabled = open && canManage && activeTab === "files";
const filesEnabled = open && isOwner && activeTab === "files";
const {
data: filesData,
isLoading: isFilesLoading,
@@ -121,7 +134,7 @@ export function SkillDetailDialog({
}, [contentData]);
const defaultTab: "detail" | "source" =
canManage && skill && !skill.detail ? "source" : "detail";
skill && !skill.detail ? "source" : "detail";
const selectedTab = activeTab ?? defaultTab;
function startEditing() {
@@ -215,18 +228,16 @@ export function SkillDetailDialog({
展示适配
</TabsTrigger>
)}
{canManage && (
{isOwner && (
<TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle}
</TabsTrigger>
)}
{canManage && (
<TabsTrigger value="source" className="text-xs">
<FileCode2Icon className="mr-1 size-3.5" />
{tx.contentSectionTitle}
</TabsTrigger>
)}
<TabsTrigger value="source" className="text-xs">
<FileCode2Icon className="mr-1 size-3.5" />
{tx.contentSectionTitle}
</TabsTrigger>
</TabsList>
<div className="flex shrink-0 items-center gap-1">
@@ -242,7 +253,6 @@ export function SkillDetailDialog({
</Button>
)}
{selectedTab === "source" &&
canManage &&
!isContentLoading &&
!contentError && (
<>
@@ -327,7 +337,7 @@ export function SkillDetailDialog({
<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" />
<p className="text-sm">{tx.detailEmpty}</p>
{canManage ? (
{isOwner ? (
<p className="mt-1 text-xs">
Open the SKILL.md tab to view the skill source.
</p>
@@ -348,7 +358,7 @@ export function SkillDetailDialog({
</TabsContent>
)}
{canManage && (
{isOwner && (
<TabsContent
value="files"
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>
)}
{canManage && (
<TabsContent
value="source"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
<TabsContent
value="source"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
@@ -407,7 +416,6 @@ export function SkillDetailDialog({
/>
)}
</TabsContent>
)}
</Tabs>
)}
</DialogContent>
@@ -27,50 +27,65 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
urlField: null,
idField: null,
linkStrategy: "auto",
linkTemplate: null,
citationToken: "doubleBracket",
maxItems: 8,
customInstruction: "",
tableColumns: [],
linkTemplate: null,
citationToken: "doubleBracket",
maxItems: 8,
customInstruction: "",
tableColumns: [],
});
export const mockKnowledgeSearchV2Result = {
success: true,
query: "latest news",
count: 3,
query: "参考文献模式测试 知识库固定测试",
count: 4,
source: "fixed-display-sample",
results: [
{
index: 1,
recUuid: "rec-001",
recUuid: "ksv2-fixed-001",
relevance_score: 0.982,
title: "Knowledge result one",
source_category: "knowledge/news",
source: "internal news library",
title: "参考文献模式测试:参考文献模式固定测试数据一",
source_category: "知识库/测试资料",
source: "knowledge-search-v2/fixed/source-001",
publish_time: "2026-05-25",
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.",
content_length: 1200,
content_preview:
"这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
content_length: 168,
},
{
index: 2,
recUuid: "rec-002",
relevance_score: 0.951,
title: "Knowledge result two",
source_category: "knowledge/news",
source: "internal news library",
recUuid: "ksv2-fixed-002",
relevance_score: 0.936,
title: "参考文献模式测试:结构化字段映射验证",
source_category: "知识库/字段映射",
source: "knowledge-search-v2/fixed/source-002",
publish_time: "2026-05-24",
content_preview: "Mock result used to verify citation ordering and detail preview.",
content_length: 980,
content_preview:
"本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
content_length: 154,
},
{
index: 3,
recUuid: "rec-003",
relevance_score: 0.921,
title: "Knowledge result three",
source_category: "knowledge/commentary",
source: "internal knowledge library",
recUuid: "ksv2-fixed-003",
relevance_score: 0.887,
title: "参考文献模式测试:多引用排序验证",
source_category: "知识库/排序验证",
source: "knowledge-search-v2/fixed/source-003",
publish_time: "2026-05-23",
content_preview: "Mock result used to verify list mode and custom display instruction previews.",
content_length: 760,
content_preview:
"本条用于验证多条参考文献的编号顺序是否与 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)
.map((item, index) => ({
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",
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
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
// configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in
// tool_metrics_middleware.py.
if (toolArgs) {
if (toolArgs && canInferSkillFromToolArgs(toolName)) {
const skillFromArgs = extractSkillNameFromArgs(toolArgs);
if (skillFromArgs) {
const direct = displays.get(skillFromArgs);
@@ -69,6 +69,51 @@ export function findDisplayConfigForTool(
}
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 {
for (const value of Object.values(args)) {
@@ -87,6 +132,7 @@ export function collectRuntimeDisplaySources(
options: {
afterMessageId?: string;
beforeMessageId?: string;
includeBeforeMessage?: boolean;
includeModes?: SkillDisplayConfig["mode"][];
} = {},
): RuntimeDisplaySource[] {
@@ -101,7 +147,8 @@ export function collectRuntimeDisplaySources(
options.beforeMessageId === undefined
? messages.length
: Math.max(
messages.findIndex((message) => message.id === options.beforeMessageId),
messages.findIndex((message) => message.id === options.beforeMessageId) +
(options.includeBeforeMessage ? 1 : 0),
0,
);
const allowedModes = options.includeModes
@@ -117,22 +164,38 @@ export function collectRuntimeDisplaySources(
continue;
}
for (const toolCall of message.tool_calls ?? []) {
const match = findDisplayConfigForTool(
const toolMatch = findDisplayConfigForTool(
toolCall.name,
displays,
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);
if (result === undefined) {
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
? normalizeDisplaySources(result, match.display)
: normalizeDefaultDisplaySources(result);
: shouldUseDefaultCitationMode(
toolCall.name,
result,
toolCall.args as Record<string, unknown> | undefined,
)
? normalizeDefaultDisplaySources(result)
: [];
for (const item of normalized) {
const skillName = match?.skillName ?? toolCall.name;
const displayKey = `${skillName}:${effectiveMode}`;
@@ -151,6 +214,113 @@ export function collectRuntimeDisplaySources(
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[] {
const normalized = normalizeSearchResults(
typeof payload === "string" || isRecord(payload)
@@ -288,23 +458,77 @@ function findToolResultPayload(
if (!toolMessage) {
return undefined;
}
const structured = extractStructuredToolPayload(toolMessage.content);
if (structured !== undefined) {
return structured;
}
const content = extractToolText(toolMessage.content);
if (!content) {
return undefined;
}
try {
return JSON.parse(content) as unknown;
} catch {
// bash stdout on the local sandbox can be suffixed with
// "\nStd Error: ..." / "\nExit Code: N" (see local_sandbox.execute_command),
// 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.
const recovered = extractJSONFromText(content);
if (recovered !== undefined) {
return recovered;
const parsed = parseJSONLikeText(content);
if (parsed !== undefined) {
return parsed;
}
return content;
}
function extractStructuredToolPayload(content: unknown): unknown {
if (isRecord(content)) {
return unwrapToolPayloadObject(content);
}
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(
skillName: string,
display: SkillDisplayConfig | null,
sample?: Record<string, unknown> | null,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
@@ -57,7 +58,7 @@ export async function setSkillDisplay(
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ display }),
body: JSON.stringify({ display, sample }),
},
);
if (!response.ok) {
@@ -71,6 +72,23 @@ export interface SkillDisplaySampleResponse {
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(
skillName: string,
): Promise<SkillDisplaySampleResponse> {
+3 -1
View File
@@ -71,10 +71,12 @@ export function useSetSkillDisplay() {
mutationFn: ({
skillName,
display,
sample,
}: {
skillName: string;
display: Parameters<typeof setSkillDisplay>[1];
}) => setSkillDisplay(skillName, display),
sample?: Parameters<typeof setSkillDisplay>[2];
}) => setSkillDisplay(skillName, display, sample),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
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.
detail?: string | null;
display?: SkillDisplayConfig | null;
displaySample?: Record<string, unknown> | null;
// Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead.
state?: SkillState;
+16 -6
View File
@@ -315,13 +315,23 @@ export function useThreadStream({
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
// Reset thread-local pending UI state when switching between threads so
// optimistic messages and in-flight guards do not leak across chat views.
// Also abort any in-flight SSE from the previous thread: without this,
// clicking "新对话" mid-stream leaves the old run firing onUpdate events
// against the now-detached thread context, which manifests as the page
// appearing unresponsive to the navigation.
// Reset thread-local pending UI state when the caller actually switches AWAY
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
// a fresh chat" signal — abort the in-flight SSE and clear optimistic state.
//
// We deliberately do NOT reset on every threadId change. During a normal
// "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(() => {
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;
if (prevThread?.isLoading) {
try {
+19 -8
View File
@@ -231,14 +231,25 @@ export default function ChatPage() {
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
{/*
Landing state covers two cases: (a) a brand-new chat that
hasn't sent anything yet, and (b) the user clicking 「新对话」
mid-stream. In (b) the underlying useStream's internal
stream.values takes one or two effect ticks to clear, so
rendering MessageList unconditionally would leak the previous
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 className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
+4 -16
View File
@@ -1,5 +1,5 @@
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";
@@ -87,20 +87,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({
pageSidebarCollapsed = false,
pageSidebarHidden = false,
@@ -135,7 +121,9 @@ export default function WorkspaceRoutes({
path="chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<KeyedChatPage render={() => <ChatPage />} />
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
File diff suppressed because it is too large. Load diff
@@ -30,7 +30,7 @@ from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskSubscriptionRow,
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.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
@@ -51,6 +51,7 @@ __all__ = [
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"SkillRow",
"SkillDisplayAdapterRow",
"TagAssignmentRow",
"TagRow",
"ThreadMetaRow",
@@ -31,3 +31,16 @@ class SkillRow(Base):
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))
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))
@@ -37,14 +37,26 @@ class LocalSandbox(Sandbox):
"""Return whether the selected shell is a PowerShell executable."""
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
@staticmethod
def _is_cmd_shell(shell: str) -> bool:
"""Return whether the selected shell is cmd.exe."""
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
@staticmethod
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
"""Return the first executable shell path or command found from candidates."""
@staticmethod
def _is_cmd_shell(shell: str) -> bool:
"""Return whether the selected shell is 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
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
"""Return the first executable shell path or command found from candidates."""
for shell in candidates:
if os.path.isabs(shell):
if os.path.isfile(shell) and os.access(shell, os.X_OK):
@@ -302,30 +314,39 @@ class LocalSandbox(Sandbox):
resolved_command = self._resolve_paths_in_command(command)
shell = self._get_shell()
if os.name == "nt":
if self._is_powershell(shell):
args = [shell, "-NoProfile", "-Command", resolved_command]
if os.name == "nt":
if self._is_powershell(shell):
args = [
shell,
"-NoProfile",
"-Command",
self._normalize_powershell_command(resolved_command),
]
elif self._is_cmd_shell(shell):
args = [shell, "/c", resolved_command]
else:
args = [shell, "-c", resolved_command]
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
timeout=600,
)
else:
args = [shell, "-c", resolved_command]
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
timeout=600,
)
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
else:
args = [shell, "-c", resolved_command]
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
output = result.stdout
if result.stderr:
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr