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

- 后端新增 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>