From 97f24f17ebfa1897fce752fa92739e22caa32345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 25 May 2026 18:19:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20RAG=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=BC=8F:=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E7=BB=93=E6=9E=9C=E6=8C=89=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=E5=BC=95=E7=94=A8/=E5=88=97=E8=A1=A8/=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端新增 skill-display 适配层(runtime/display-adapter)与可视化适配面板, 支持从技能返回 JSON 自动识别字段并配置 citation/list/table/card 展示 - 正文 [[N]] 编号渲染为可点击徽章,点击不跳 URL 而是定位到右侧参考文献面板; 参考卡片样式重做(圆形编号、悬停高亮、过滤掉 "result" 占位类型) - bash 工具调用按命令中的 skills/{public,custom}// 路径反查展示适配, 解决调用名为 "bash" 时无法匹配到具体技能的问题 - 工具结果 JSON 解析放宽:能从 stderr/exit code 噪声尾巴里抢救出 JSON 主体 - 展示适配自动识别接入 LLM:读 SKILL.md 让模型推断真实返回结构,失败回退 mock - 展示适配面板修复保存回显:保存后表单跟随服务端最新值刷新,样例文本不再被抹掉 - 设置页加 "RAG 引用模式" 开关(默认开),关联 lead-agent prompt 注入引用规则 --- .../components/workspace/chats/chat-box.tsx | 532 ++++++++++++------ .../workspace/citations/citation-link.tsx | 160 +++--- .../src/components/workspace/input-box.tsx | 44 +- .../workspace/messages/message-group.tsx | 87 ++- .../settings/appearance-settings-page.tsx | 25 +- .../workspace/skill-display-adapter-panel.tsx | 360 ++++++++++++ .../workspace/skill-display-block.tsx | 184 ++++++ .../workspace/skill-display-sources.tsx | 155 +++++ .../workspace/skills/skill-detail-dialog.tsx | 20 +- frontend-web/src/core/settings/local.ts | 16 +- .../src/core/skill-display/display-adapter.ts | 209 +++++++ .../src/core/skill-display/runtime.ts | 350 ++++++++++++ frontend-web/src/core/skills/api.ts | 44 +- frontend-web/src/core/skills/hooks.ts | 30 +- frontend-web/src/core/skills/type.ts | 31 + frontend-web/src/core/threads/hooks.ts | 15 +- frontend-web/src/core/threads/types.ts | 5 +- .../backend/app/gateway/routers/mcp.py | 11 +- .../backend/app/gateway/routers/skills.py | 265 ++++++++- .../backend/app/gateway/services.py | 5 +- .../packages/harness/deerflow/client.py | 32 +- .../deerflow/config/extensions_config.py | 63 ++- 22 files changed, 2313 insertions(+), 330 deletions(-) create mode 100644 frontend-web/src/components/workspace/skill-display-adapter-panel.tsx create mode 100644 frontend-web/src/components/workspace/skill-display-block.tsx create mode 100644 frontend-web/src/components/workspace/skill-display-sources.tsx create mode 100644 frontend-web/src/core/skill-display/display-adapter.ts create mode 100644 frontend-web/src/core/skill-display/runtime.ts diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index dec47e42..ce37986a 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -1,183 +1,349 @@ -import { FilesIcon, XIcon } from "lucide-react"; -import { usePathname } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { GroupImperativeHandle } from "react-resizable-panels"; - -import { ConversationEmptyState } from "@/components/ai-elements/conversation"; -import { Button } from "@/components/ui/button"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { env } from "@/env"; -import { cn } from "@/lib/utils"; - -import { - ArtifactFileDetail, - ArtifactFileList, - useArtifacts, -} from "../artifacts"; -import { useThread } from "../messages/context"; - -const CLOSE_MODE = { chat: 100, artifacts: 0 }; -const OPEN_MODE = { chat: 60, artifacts: 40 }; - -const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ - children, - threadId, -}) => { - const { thread } = useThread(); - const pathname = usePathname(); - const threadIdRef = useRef(threadId); - const layoutRef = useRef(null); - - const { - artifacts, - open: artifactsOpen, - setOpen: setArtifactsOpen, - setArtifacts, - select: selectArtifact, - deselect, - selectedArtifact, - } = useArtifacts(); - - const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); - useEffect(() => { - if (threadIdRef.current !== threadId) { - threadIdRef.current = threadId; - deselect(); - } - - // Update artifacts from the current thread - setArtifacts(thread.values.artifacts); - - // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. - // if ( - // selectedArtifact && - // !thread.values.artifacts?.includes(selectedArtifact) - // ) { - // deselect(); - // } - - if ( - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && - autoSelectFirstArtifact - ) { - if (thread?.values?.artifacts?.length > 0) { - setAutoSelectFirstArtifact(false); - selectArtifact(thread.values.artifacts[0]!); - } - } - }, [ - threadId, - autoSelectFirstArtifact, - deselect, - selectArtifact, - selectedArtifact, - setArtifacts, - thread.values.artifacts, - ]); - - const artifactPanelOpen = useMemo(() => { - if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { - return artifactsOpen && artifacts?.length > 0; - } - return artifactsOpen; - }, [artifactsOpen, artifacts]); - - const resizableIdBase = useMemo(() => { - const raw = pathname - .replace(/[^a-zA-Z0-9_-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return raw.length > 0 ? raw : `thread-${threadId}`; - }, [pathname, threadId]); - - useEffect(() => { - if (layoutRef.current) { - if (artifactPanelOpen) { - layoutRef.current.setLayout(OPEN_MODE); - } else { - layoutRef.current.setLayout(CLOSE_MODE); - } - } - }, [artifactPanelOpen]); - - return ( - - - {children} - - - -
- {selectedArtifact ? ( - - ) : ( -
-
- -
- {thread.values.artifacts?.length === 0 ? ( - } - title="No artifact selected" - description="Select an artifact to view its details" - /> - ) : ( -
-
-

Artifacts

-
-
- -
-
- )} -
- )} -
-
-
- ); -}; - -export { ChatBox }; +import type { Message } from "@langchain/langgraph-sdk"; +import { FilesIcon, XIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { GroupImperativeHandle } from "react-resizable-panels"; + +import { ConversationEmptyState } from "@/components/ai-elements/conversation"; +import { Button } from "@/components/ui/button"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useLocalSettings } from "@/core/settings"; +import { useSkills } from "@/core/skills/hooks"; +import { + buildSkillDisplayMap, + collectRuntimeDisplaySources, +} from "@/core/skill-display/runtime"; +import { env } from "@/env"; +import { cn } from "@/lib/utils"; + +import { + ArtifactFileDetail, + ArtifactFileList, + useArtifacts, +} from "../artifacts"; +import { useThread } from "../messages/context"; +import { SkillDisplaySources } from "../skill-display-sources"; + +const CLOSE_MODE = { chat: 100, artifacts: 0 }; +const OPEN_MODE = { chat: 60, artifacts: 40 }; +const SHOW_REFERENCE_EVENT = "workspace:show-reference"; + +const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ + children, + threadId, +}) => { + const { thread } = useThread(); + const pathname = usePathname(); + const threadIdRef = useRef(threadId); + const layoutRef = useRef(null); + const [sideTab, setSideTab] = useState("document"); + const [referencesOpen, setReferencesOpen] = useState(true); + const [settings] = useLocalSettings(); + const { skills } = useSkills(); + + const { + artifacts, + open: artifactsOpen, + setOpen: setArtifactsOpen, + setArtifacts, + select: selectArtifact, + deselect, + selectedArtifact, + } = useArtifacts(); + + const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); + const skillDisplayMap = useMemo( + () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), + [settings.display.ragModeEnabled, skills], + ); + const latestAssistant = useMemo( + () => findLatestAssistantMessage(thread.values.messages ?? []), + [thread.values.messages], + ); + const displaySources = useMemo(() => { + if (!settings.display.ragModeEnabled) { + return []; + } + const messages = thread.values.messages ?? []; + if (!latestAssistant?.id) { + return []; + } + return collectRuntimeDisplaySources(messages, skillDisplayMap, { + afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id), + beforeMessageId: latestAssistant.id, + }); + }, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, thread.values.messages]); + const referenceSources = useMemo( + () => displaySources.filter((source) => source.mode === "citation"), + [displaySources], + ); + const hasCitationReferences = referenceSources.length > 0; + useEffect(() => { + if (hasCitationReferences) { + setReferencesOpen(true); + } + }, [hasCitationReferences, latestAssistant?.id]); + useEffect(() => { + const handleShowReference = () => { + setReferencesOpen(true); + setSideTab("references"); + }; + window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference); + return () => { + window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference); + }; + }, []); + useEffect(() => { + if (threadIdRef.current !== threadId) { + threadIdRef.current = threadId; + deselect(); + setReferencesOpen(true); + } + + // Update artifacts from the current thread + setArtifacts(thread.values.artifacts); + + // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. + // if ( + // selectedArtifact && + // !thread.values.artifacts?.includes(selectedArtifact) + // ) { + // deselect(); + // } + + if ( + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && + autoSelectFirstArtifact + ) { + if (thread?.values?.artifacts?.length > 0) { + setAutoSelectFirstArtifact(false); + selectArtifact(thread.values.artifacts[0]!); + } + } + }, [ + threadId, + autoSelectFirstArtifact, + deselect, + selectArtifact, + selectedArtifact, + setArtifacts, + thread.values.artifacts, + ]); + + const artifactPanelOpen = useMemo(() => { + if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { + return artifactsOpen && artifacts?.length > 0; + } + return artifactsOpen; + }, [artifactsOpen, artifacts]); + const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen); + const showTabs = !!selectedArtifact && hasCitationReferences; + + useEffect(() => { + if (!selectedArtifact && hasCitationReferences) { + setSideTab("references"); + } else if (selectedArtifact && !hasCitationReferences) { + setSideTab("document"); + } + }, [hasCitationReferences, selectedArtifact]); + + const resizableIdBase = useMemo(() => { + const raw = pathname + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return raw.length > 0 ? raw : `thread-${threadId}`; + }, [pathname, threadId]); + + useEffect(() => { + if (layoutRef.current) { + if (sidePanelOpen) { + layoutRef.current.setLayout(OPEN_MODE); + } else { + layoutRef.current.setLayout(CLOSE_MODE); + } + } + }, [sidePanelOpen]); + + return ( + + + {children} + + + +
+ {showTabs ? ( + +
+ + 文档 + 参考文献 + + +
+ + + + + + +
+ ) : selectedArtifact ? ( + + ) : hasCitationReferences ? ( +
+
+ +
+ +
+ ) : ( +
+
+ +
+ {thread.values.artifacts?.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

Artifacts

+
+
+ +
+
+ )} +
+ )} +
+
+
+ ); +}; + +function ReferencePanel({ + sources, +}: { + sources: Parameters[0]["sources"]; +}) { + return ( +
+
+
+

+ 参考文献 +

+ + {sources.length} 条 + +
+

+ 点击编号或卡片可查看引用详情。 +

+
+
+ +
+
+ ); +} + +function findLatestAssistantMessage(messages: Message[]) { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.type === "ai") { + return 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 }; diff --git a/frontend-web/src/components/workspace/citations/citation-link.tsx b/frontend-web/src/components/workspace/citations/citation-link.tsx index aecc71a8..58b7c22a 100644 --- a/frontend-web/src/components/workspace/citations/citation-link.tsx +++ b/frontend-web/src/components/workspace/citations/citation-link.tsx @@ -1,82 +1,78 @@ -import { ExternalLinkIcon } from "lucide-react"; -import type { ComponentProps } from "react"; - -import { Badge } from "@/components/ui/badge"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { cn } from "@/lib/utils"; - -export function CitationLink({ - href, - children, - ...props -}: ComponentProps<"a">) { - const domain = extractDomain(href ?? ""); - - // Priority: children > domain - const childrenText = - typeof children === "string" - ? children.replace(/^citation:\s*/i, "") - : null; - const isGenericText = childrenText === "Source" || childrenText === "来源"; - const displayText = (!isGenericText && childrenText) ?? domain; - - return ( - - - e.stopPropagation()} - {...props} - > - - {displayText} - - - - - -
-
- {displayText && ( -

- {displayText} -

- )} - {href && ( -

- {href} -

- )} -
- - Visit source - - -
-
-
- ); -} - -function extractDomain(url: string): string { - try { - return new URL(url).hostname.replace(/^www\./i, ""); - } catch { - return url; - } -} +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +const SHOW_REFERENCE_EVENT = "workspace:show-reference"; + +export function CitationLink({ + href, + children, + className, + ...props +}: ComponentProps<"a">) { + const childrenText = normalizeCitationText(children); + const displayText = childrenText ?? ""; + const referenceId = href?.startsWith("#reference-") + ? href.slice("#reference-".length) + : null; + + if (referenceId) { + const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId; + return ( + { + event.preventDefault(); + window.dispatchEvent( + new CustomEvent(SHOW_REFERENCE_EVENT, { + detail: { referenceId }, + }), + ); + const target = document.getElementById(`reference-${referenceId}`); + const scroll = () => { + target?.scrollIntoView({ behavior: "smooth", block: "center" }); + }; + if (target) { + scroll(); + } else { + window.setTimeout(scroll, 220); + } + }} + {...props} + > + {badgeLabel} + + ); + } + + return ( + + {displayText || children} + + ); +} + +function normalizeCitationText(children: ComponentProps<"a">["children"]) { + if (typeof children !== "string") { + return null; + } + let text = children.trim(); + while (/^citation:\s*/i.test(text)) { + text = text.replace(/^citation:\s*/i, ""); + } + return text; +} diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 986c5423..3a524c90 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -2,8 +2,9 @@ import type { ChatStatus } from "ai"; import { - BrainCircuitIcon, - CheckIcon, + BrainCircuitIcon, + BookMarkedIcon, + CheckIcon, FileTextIcon, GraduationCapIcon, LightbulbIcon, @@ -85,7 +86,8 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; +import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; +import { useLocalSettings } from "@/core/settings"; import { useThread } from "./messages/context"; @@ -176,7 +178,8 @@ export function InputBox({ onAgentChange: (agentId: string | null) => void; }; }) { - const { t } = useI18n(); + const { t } = useI18n(); + const [localSettings, setLocalSettings] = useLocalSettings(); const searchParams = useSearchParams(); const [modelDialogOpen, setModelDialogOpen] = useState(false); const { models } = useModels(); @@ -289,13 +292,18 @@ export function InputBox({ }); }, [context, onContextChange]); - const memoryInjectionEnabled = context.memory_injection_enabled ?? true; + const memoryInjectionEnabled = context.memory_injection_enabled ?? true; const handleMemoryInjectionToggle = useCallback(() => { onContextChange?.({ ...context, memory_injection_enabled: !memoryInjectionEnabled, }); - }, [context, memoryInjectionEnabled, onContextChange]); + }, [context, memoryInjectionEnabled, onContextChange]); + + const ragModeEnabled = localSettings.display.ragModeEnabled; + const handleRagModeToggle = useCallback(() => { + setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); + }, [ragModeEnabled, setLocalSettings]); const handleSubmit = useCallback( async (message: PromptInputMessage) => { @@ -621,8 +629,28 @@ export function InputBox({ 记忆 - - + + + + + 参考文献 + + + convertToSteps(messages), [messages]); + const [settings] = useLocalSettings(); + const { skills } = useSkills(); + const skillDisplayMap = useMemo( + () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), + [settings.display.ragModeEnabled, skills], + ); const lastToolCallStep = useMemo(() => { const filteredSteps = steps.filter((step) => step.type === "toolCall"); return filteredSteps[filteredSteps.length - 1]; @@ -142,7 +158,13 @@ export function MessageGroup({ } > ) : ( - + ), )} {lastToolCallStep && ( @@ -152,6 +174,8 @@ export function MessageGroup({ {...lastToolCallStep} isLast={true} isLoading={isLoading} + skillDisplayMap={skillDisplayMap} + ragModeEnabled={settings.display.ragModeEnabled} /> )} @@ -209,6 +233,8 @@ function ToolCall({ result, isLast = false, isLoading = false, + skillDisplayMap = new Map(), + ragModeEnabled = false, }: { id?: string; messageId?: string; @@ -217,11 +243,48 @@ function ToolCall({ result?: string | Record; isLast?: boolean; isLoading?: boolean; + skillDisplayMap?: ReturnType; + ragModeEnabled?: boolean; }) { const { t } = useI18n(); const artifacts = useArtifactsOptional(); + const displayMatch = findDisplayConfigForTool(name, skillDisplayMap, args); + const displayItems = + displayMatch && result !== undefined + ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ + ...item, + index: index + 1, + skillName: displayMatch.skillName, + mode: displayMatch.display.mode, + })) + : []; + if (displayItems.length > 0) { + return ( + + + + ); + } + const searchItems = normalizeSearchResults(result); + if (ragModeEnabled && searchItems.length > 0) { + const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({ + ...item, + index: index + 1, + skillName: name, + mode: "citation" as const, + })); + return ( + + + + ); + } if (name === "web_search" || searchItems.length > 0) { const query = resolveSearchQuery(args, result); let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; @@ -544,6 +607,28 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) { ); } +function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) { + const [open, setOpen] = useState(false); + return ( + + + + {results.length} 条适配结果 + + + {results.map((item) => ( + + ))} + + + ); +} + function SearchResultCard({ item }: { item: SearchResultItem }) { const [open, setOpen] = useState(false); const content = item.snippet ?? item.content; diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index 77c548a1..1a35e372 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -124,11 +124,26 @@ export function AppearanceSettingsPage() { } aria-label="回答速度指示器" /> - - - - ); -} + +
+
+
RAG 引用模式
+

+ 开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。 +

+
+ + setSettings("display", { ragModeEnabled: checked }) + } + aria-label="RAG 引用模式" + /> +
+ + + ); +} function ThemePreviewCard({ icon: Icon, diff --git a/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx b/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx new file mode 100644 index 00000000..ed8d2071 --- /dev/null +++ b/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { + emptyDisplayConfig, + inferDisplayConfig, + mockKnowledgeSearchV2Result, + normalizeDisplaySources, +} from "@/core/skill-display/display-adapter"; +import { getSkillDisplaySample } from "@/core/skills/api"; +import { useSetSkillDisplay } from "@/core/skills/hooks"; +import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type"; + +const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [ + ["resultPath", "结果列表路径"], + ["titleField", "标题字段"], + ["summaryField", "摘要字段"], + ["contentField", "正文内容字段"], + ["sourceField", "来源字段"], + ["typeField", "类型字段"], + ["timeField", "时间字段"], + ["scoreField", "相关度字段"], + ["urlField", "URL 跳转字段"], + ["idField", "ID 跳转字段"], + ["linkTemplate", "ID 跳转链接模板"], +]; + +const displayModeLabels: Record = { + citation: "编号引用", + list: "列表展示", + table: "表格展示", + card: "卡片展示", + custom: "自定义展示", + none: "不展示", +}; + +const linkStrategyLabels: Record = { + auto: "自动判断", + url: "使用 URL 字段跳转", + id: "使用 ID 模板跳转", +}; + +function updateConfig( + config: SkillDisplayConfig, + key: K, + value: SkillDisplayConfig[K], +): SkillDisplayConfig { + return { ...config, [key]: value }; +} + +export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) { + const setDisplay = useSetSkillDisplay(); + const [draft, setDraft] = useState(() => skill.display ?? emptyDisplayConfig()); + const [sampleText, setSampleText] = useState(() => JSON.stringify(mockKnowledgeSearchV2Result, 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 + // a successful save (the refetched skill object has a new `display` identity). + // Important: this drives the "保存后回显" behavior. Without depending on + // `skill.display`, any backend-side normalization (e.g. default values + // backfilled by Pydantic, column reordering) would not flow back into the UI. + useEffect(() => { + const display = skill.display ?? emptyDisplayConfig(); + setDraft(display); + setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2)); + }, [skill.name, skill.display]); + + // Sample text is editor-only state — never reset on save. Only seed it once + // 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]); + + const samplePayload = useMemo(() => { + try { + return JSON.parse(sampleText); + } catch { + return null; + } + }, [sampleText]); + + const previewItems = useMemo(() => ( + samplePayload ? normalizeDisplaySources(samplePayload, draft) : [] + ), [draft, samplePayload]); + + function generateDraft() { + if (!samplePayload) { + toast.error("样例返回不是有效的 JSON"); + return; + } + const nextDraft = inferDisplayConfig(samplePayload, skill.name); + setDraft(nextDraft); + setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2)); + toast.success("已生成展示适配草稿"); + } + + async function loadSampleAndGenerate() { + try { + const response = await getSkillDisplaySample(skill.name); + const nextSampleText = JSON.stringify(response.sample, null, 2); + setSampleText(nextSampleText); + const nextDraft = inferDisplayConfig(response.sample, skill.name); + setDraft(nextDraft); + setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2)); + toast.success("已获取样例并生成展示适配"); + } catch (err) { + generateDraft(); + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + async function saveDraft() { + try { + await setDisplay.mutateAsync({ skillName: skill.name, display: draft }); + toast.success("展示适配已保存"); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + async function clearDisplay() { + try { + await setDisplay.mutateAsync({ skillName: skill.name, display: null }); + setDraft(emptyDisplayConfig()); + setTableColumnsText(JSON.stringify([], null, 2)); + toast.success("展示适配已清除"); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + return ( +
+
+
+
+
技能展示适配
+

+ 从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。 +

+
+
+ 启用 + setDraft(updateConfig(draft, "enabled", checked))} + /> +
+
+ +
+ + + + + +
+ +
+ {fieldLabels.map(([field, label]) => ( + + ))} +
+ + {draft.mode === "table" && ( +