From b2ae22c4ebeea5b9a894cf0dfa00e8e47cfd29b0 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Thu, 23 Jul 2026 19:40:36 +0800 Subject: [PATCH] feat(chat): show wiki tool results in the references drawer Parse wiki search/read/edit/issue tool outputs into structured reference cards so agent citations and the right-side drawer work consistently with other retrieval tools. --- .../src/components/ChatReferencesDrawer.vue | 2 +- frontend/src/utils/wikiToolReferences.test.ts | 76 ++++++++++ frontend/src/utils/wikiToolReferences.ts | 87 +++++++++++ .../chat/components/AgentStreamDisplay.vue | 136 ++++++++++++++++-- .../chat/components/chatLinksNewTab.test.mjs | 17 +++ 5 files changed, 303 insertions(+), 15 deletions(-) create mode 100644 frontend/src/utils/wikiToolReferences.test.ts create mode 100644 frontend/src/utils/wikiToolReferences.ts diff --git a/frontend/src/components/ChatReferencesDrawer.vue b/frontend/src/components/ChatReferencesDrawer.vue index d3c881f8..9001acea 100644 --- a/frontend/src/components/ChatReferencesDrawer.vue +++ b/frontend/src/components/ChatReferencesDrawer.vue @@ -110,7 +110,7 @@
{{ item.title }}
-

+

{{ formatReferenceSnippet(item.snippet) }}

diff --git a/frontend/src/utils/wikiToolReferences.test.ts b/frontend/src/utils/wikiToolReferences.test.ts new file mode 100644 index 00000000..1b15c544 --- /dev/null +++ b/frontend/src/utils/wikiToolReferences.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { parseWikiToolReferences } from './wikiToolReferences.ts' + +test('parses wiki search hits into individual drawer references', () => { + const refs = parseWikiToolReferences('wiki_search', ` + + + kb-1 + [[concept/rag|Retrieval-Augmented Generation]] + concept + Grounds answers in retrieved evidence. + RAG combines retrieval and generation. + + + kb-2 + [[summary/search]] + Search overview. + + + `, 'call-1') + + assert.deepEqual(refs, [ + { + id: 'call-1:kb-1:concept/rag', + title: 'Retrieval-Augmented Generation', + content: 'Grounds answers in retrieved evidence.\n\nRAG combines retrieval and generation.', + knowledgeBaseId: 'kb-1', + slug: 'concept/rag', + }, + { + id: 'call-1:kb-2:summary/search', + title: 'summary/search', + content: 'Search overview.', + knowledgeBaseId: 'kb-2', + slug: 'summary/search', + }, + ]) +}) + +test('parses wiki page reads and keeps markdown content', () => { + const refs = parseWikiToolReferences('wiki_read_page', ` + + + kb-1 + [[concept/rag|RAG]] + + Short summary. + # RAG\n\nFull **Markdown** body. + + `, 'call-2') + + assert.equal(refs.length, 1) + assert.equal(refs[0]?.title, 'RAG') + assert.equal(refs[0]?.slug, 'concept/rag') + assert.equal(refs[0]?.knowledgeBaseId, 'kb-1') + assert.equal(refs[0]?.content, '# RAG\n\nFull **Markdown** body.') +}) + +test('uses the wiki summary only when a page has no body', () => { + const refs = parseWikiToolReferences('wiki_read_page', ` + + [[summary/empty|Empty page]] + Summary fallback. + + + `, 'call-3') + + assert.equal(refs[0]?.content, 'Summary fallback.') +}) + +test('ignores non-wiki tools and empty result sets', () => { + assert.deepEqual(parseWikiToolReferences('web_search', 'ignored'), []) + assert.deepEqual(parseWikiToolReferences('wiki_search', ''), []) +}) diff --git a/frontend/src/utils/wikiToolReferences.ts b/frontend/src/utils/wikiToolReferences.ts new file mode 100644 index 00000000..9879070b --- /dev/null +++ b/frontend/src/utils/wikiToolReferences.ts @@ -0,0 +1,87 @@ +export type WikiToolReference = { + id: string + title: string + content: string + knowledgeBaseId?: string + slug?: string +} + +function extractBlocks(value: string, tag: string): string[] { + const pattern = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'gi') + return Array.from(value.matchAll(pattern), (match) => match[1] || '') +} + +function extractTag(value: string, tag: string): string { + return extractBlocks(value, tag)[0]?.trim() || '' +} + +function parseWikiLink(value: string): { slug: string; title: string } { + const match = String(value || '').match(/\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/) + const slug = match?.[1]?.trim() || '' + return { + slug, + title: match?.[2]?.trim() || slug, + } +} + +function joinDistinct(parts: string[]): string { + const result: string[] = [] + for (const part of parts) { + const value = part.trim() + if (value && !result.includes(value)) result.push(value) + } + return result.join('\n\n') +} + +/** + * Convert the XML-like model payloads emitted by the Wiki read tools into + * individual drawer cards. The payload is intentionally parsed leniently: + * page content is Markdown and is not guaranteed to be valid XML. + */ +export function parseWikiToolReferences( + toolName: string, + output: unknown, + toolCallId = toolName, +): WikiToolReference[] { + if (typeof output !== 'string' || !output.trim()) return [] + + if (toolName === 'wiki_search') { + return extractBlocks(output, 'page').flatMap((page, index) => { + const { slug, title } = parseWikiLink(extractTag(page, 'link')) + const content = joinDistinct([ + extractTag(page, 'summary'), + extractTag(page, 'match_snippet'), + ]) + if (!slug && !title && !content) return [] + const knowledgeBaseId = extractTag(page, 'knowledge_base_id') || undefined + return [{ + id: `${toolCallId}:${knowledgeBaseId || 'wiki'}:${slug || index + 1}`, + title: title || slug || `Wiki ${index + 1}`, + content, + knowledgeBaseId, + slug: slug || undefined, + }] + }) + } + + if (toolName === 'wiki_read_page') { + return extractBlocks(output, 'wiki_page').flatMap((page, index) => { + const { slug, title } = parseWikiLink(extractTag(page, 'link')) + // The page body commonly begins with the same introduction stored in + // summary. Showing both makes the drawer look as if its first paragraph + // was duplicated, so summary is only a fallback for body-less pages. + const content = extractTag(page, 'content') || extractTag(page, 'summary') + if (!slug && !title && !content) return [] + const knowledgeBaseId = extractTag(page, 'knowledge_base_id') || undefined + return [{ + id: `${toolCallId}:${knowledgeBaseId || 'wiki'}:${slug || index + 1}`, + title: title || slug || `Wiki ${index + 1}`, + content, + knowledgeBaseId, + slug: slug || undefined, + }] + }) + } + + return [] +} diff --git a/frontend/src/views/chat/components/AgentStreamDisplay.vue b/frontend/src/views/chat/components/AgentStreamDisplay.vue index ab50cc7f..715c5887 100644 --- a/frontend/src/views/chat/components/AgentStreamDisplay.vue +++ b/frontend/src/views/chat/components/AgentStreamDisplay.vue @@ -510,6 +510,7 @@ import { hydrateProtectedFileImages, clearProtectedFileFailureCache, sanitizeMar import { unwrapFinalAnswerWrappers, thinkingEqualsAnswer } from '@/utils/finalAnswer'; import { getAgentToolIconName } from '@/utils/agent-tool-icons'; import { getQueryText, getWikiPageText } from '@/utils/agent-tool-display'; +import { parseWikiToolReferences } from '@/utils/wikiToolReferences'; import { buildManualMarkdown, copyTextToClipboard, @@ -558,6 +559,13 @@ const TOOL_NAME_KEYS: Record = { wiki_search: 'agentEditor.tools.wikiSearch', wiki_read_page: 'agentEditor.tools.wikiReadPage', wiki_read_source_doc: 'agentStream.tools.wikiReadSourceDoc', + wiki_flag_issue: 'agentEditor.tools.wikiFlagIssue', + wiki_write_page: 'agentEditor.tools.wikiWritePage', + wiki_replace_text: 'agentEditor.tools.wikiReplaceText', + wiki_rename_page: 'agentEditor.tools.wikiRenamePage', + wiki_delete_page: 'agentEditor.tools.wikiDeletePage', + wiki_read_issue: 'agentEditor.tools.wikiReadIssue', + wiki_update_issue: 'agentEditor.tools.wikiUpdateIssue', todo_write: 'agentStream.tools.todoWrite', knowledge_graph_extract: 'agentStream.tools.knowledgeGraphExtract', thinking: 'agentStream.tools.thinking', @@ -916,6 +924,84 @@ const formatToolResultContent = (value: unknown): string => { const isMcpTool = (toolName?: string | null): boolean => String(toolName || '').startsWith('mcp_'); +const WIKI_EDIT_TOOL_NAMES = new Set([ + 'wiki_write_page', + 'wiki_replace_text', + 'wiki_rename_page', + 'wiki_delete_page', +]); + +const WIKI_ISSUE_TOOL_NAMES = new Set([ + 'wiki_flag_issue', + 'wiki_read_issue', + 'wiki_update_issue', +]); + +const formatWikiEditResultContent = (toolData: any): string => { + const rows: Array<[string, unknown]> = []; + switch (toolData?.display_type) { + case 'wiki_write_page': + rows.push( + [t('chat.wikiFieldSlug'), toolData.slug], + [t('chat.wikiFieldTitle'), toolData.title], + [t('chat.wikiFieldPageType'), toolData.page_type], + [t('chat.wikiFieldSummary'), toolData.summary], + ); + break; + case 'wiki_replace_text': + rows.push( + [t('chat.wikiFieldSlug'), toolData.slug], + [t('chat.wikiFieldTitle'), toolData.title], + [t('chat.wikiFieldOldText'), toolData.old_text], + [t('chat.wikiFieldNewText'), toolData.new_text], + ); + break; + case 'wiki_rename_page': + rows.push( + [t('chat.wikiFieldOldSlug'), toolData.old_slug], + [t('chat.wikiFieldNewSlug'), toolData.new_slug], + [t('chat.wikiFieldTitle'), toolData.title], + [t('chat.wikiFieldAffectedPages'), Array.isArray(toolData.affected_pages) + ? toolData.affected_pages.join(', ') + : toolData.affected_pages], + ); + break; + case 'wiki_delete_page': + rows.push( + [t('chat.wikiFieldSlug'), toolData.slug], + [t('chat.wikiFieldTitle'), toolData.title], + [t('chat.wikiFieldAffectedPages'), Array.isArray(toolData.affected_pages) + ? toolData.affected_pages.join(', ') + : toolData.affected_pages], + ); + break; + } + return rows + .filter(([, value]) => value !== undefined && value !== null && String(value).trim()) + .map(([label, value]) => `${label}: ${String(value).trim()}`) + .join('\n'); +}; + +const buildToolResultReference = ( + event: any, + content: string, +): KnowledgeReferenceLike[] => { + if (!content) return []; + const toolName = String(event.tool_name || ''); + const title = getToolTitle(event); + return [{ + id: event.tool_call_id || toolName, + chunk_type: 'tool_result', + knowledge_title: title, + content, + metadata: { + title, + source: getLocalizedToolName(toolName), + tool: toolName, + }, + }]; +}; + function getToolReferenceItems(event: any): KnowledgeReferenceLike[] { if (!event || event.pending) return []; const toolName = event.tool_name; @@ -923,22 +1009,39 @@ function getToolReferenceItems(event: any): KnowledgeReferenceLike[] { if (isMcpTool(toolName)) { const output = formatToolResultContent(event.output) || formatToolResultContent(toolData); - if (!output) return []; - return [{ - id: event.tool_call_id || toolName, - chunk_type: 'tool_result', - knowledge_title: getToolTitle(event), - content: output, - metadata: { - title: getToolTitle(event), - source: getLocalizedToolName(toolName), - tool: String(toolName || ''), - }, - }]; + return buildToolResultReference(event, output); + } + + if (WIKI_ISSUE_TOOL_NAMES.has(toolName)) { + return buildToolResultReference(event, formatToolResultContent(event.output)); } if (!toolData) return []; + if (toolName === 'wiki_search' || toolName === 'wiki_read_page') { + return parseWikiToolReferences(toolName, event.output, event.tool_call_id || toolName) + .map((item) => ({ + id: item.id, + chunk_type: 'tool_result', + knowledge_title: item.title, + content: item.content, + metadata: { + title: item.title, + source: getLocalizedToolName(toolName), + tool: toolName, + ...(item.slug ? { slug: item.slug } : {}), + ...(item.knowledgeBaseId ? { knowledge_base_id: item.knowledgeBaseId } : {}), + }, + })); + } + + if (WIKI_EDIT_TOOL_NAMES.has(toolName)) { + return buildToolResultReference( + event, + formatWikiEditResultContent(toolData) || formatToolResultContent(event.output), + ); + } + if (toolName === 'web_search') { const results = Array.isArray(toolData.results) ? toolData.results : []; return results @@ -1021,7 +1124,7 @@ function getToolReferenceItems(event: any): KnowledgeReferenceLike[] { }))); } - if (toolName === 'list_knowledge_chunks') { + if (toolName === 'list_knowledge_chunks' || toolName === 'wiki_read_source_doc') { const chunks = Array.isArray(toolData.chunks) ? toolData.chunks : []; if (chunks.length) { return mergeDocumentReferences(chunks @@ -1803,12 +1906,17 @@ const isEventExpanded = (eventId: string): boolean => { const isReferenceDrawerTool = (toolName?: string | null): boolean => isMcpTool(toolName) || + WIKI_EDIT_TOOL_NAMES.has(String(toolName || '')) || + WIKI_ISSUE_TOOL_NAMES.has(String(toolName || '')) || toolName === 'search_knowledge' || toolName === 'knowledge_search' || toolName === 'web_search' || toolName === 'web_fetch' || toolName === 'grep_chunks' || - toolName === 'list_knowledge_chunks'; + toolName === 'list_knowledge_chunks' || + toolName === 'wiki_search' || + toolName === 'wiki_read_page' || + toolName === 'wiki_read_source_doc'; const hasExpandableResults = (event: any): boolean => { if (isReferenceDrawerTool(event?.tool_name)) return false; diff --git a/frontend/src/views/chat/components/chatLinksNewTab.test.mjs b/frontend/src/views/chat/components/chatLinksNewTab.test.mjs index 72a95e6b..06264f42 100644 --- a/frontend/src/views/chat/components/chatLinksNewTab.test.mjs +++ b/frontend/src/views/chat/components/chatLinksNewTab.test.mjs @@ -57,6 +57,23 @@ test('agent citations recover drawer references from retrieval tool events', () ) }) +test('wiki tool results use the right-side references drawer', () => { + assert.match( + agentStream, + /toolName === 'wiki_search' \|\| toolName === 'wiki_read_page'[\s\S]*?parseWikiToolReferences/, + ) + assert.match( + agentStream, + /toolName === 'list_knowledge_chunks' \|\| toolName === 'wiki_read_source_doc'/, + ) + assert.match( + agentStream, + /const isReferenceDrawerTool =[\s\S]*?toolName === 'wiki_search'[\s\S]*?toolName === 'wiki_read_page'[\s\S]*?toolName === 'wiki_read_source_doc'/, + ) + assert.match(agentStream, /WIKI_EDIT_TOOL_NAMES\.has\(String\(toolName \|\| ''\)\)/) + assert.match(agentStream, /WIKI_ISSUE_TOOL_NAMES\.has\(String\(toolName \|\| ''\)\)/) +}) + test('citation highlighting waits for drawer entry and only scrolls its own body', () => { assert.match(referenceDrawer, /@after-enter="handlePanelAfterEnter"/) assert.match(referenceDrawer, /if \(!panelEntered\.value\) return/)