技能展示适配持久化 + 参考文献收集与新对话流式修复
- 后端新增 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:
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>
|
||||
|
||||
Reference in new issue
Block a user