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.
This commit is contained in:
wizardchen
2026-07-23 19:40:36 +08:00
committed by lyingbug
parent 6fb858105a
commit b2ae22c4eb
5 changed files with 303 additions and 15 deletions
@@ -110,7 +110,7 @@
<h5 v-if="shouldShowItemTitle(item)" class="reference-item__title">{{ item.title }}</h5>
<p v-if="item.snippet && !expandedKeys.has(item.key)" class="reference-item__snippet">
<p v-if="item.kind !== 'tool' && item.snippet && !expandedKeys.has(item.key)" class="reference-item__snippet">
{{ formatReferenceSnippet(item.snippet) }}
</p>
<div v-if="item.kind === 'tool' && item.content" class="reference-item__content">
@@ -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', `
<search_results count="2" query="RAG">
<page>
<knowledge_base_id>kb-1</knowledge_base_id>
<link>[[concept/rag|Retrieval-Augmented Generation]]</link>
<type>concept</type>
<summary>Grounds answers in retrieved evidence.</summary>
<match_snippet>RAG combines retrieval and generation.</match_snippet>
</page>
<page>
<knowledge_base_id>kb-2</knowledge_base_id>
<link>[[summary/search]]</link>
<summary>Search overview.</summary>
</page>
</search_results>
`, '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', `
<wiki_page>
<metadata>
<knowledge_base_id>kb-1</knowledge_base_id>
<link>[[concept/rag|RAG]]</link>
</metadata>
<summary>Short summary.</summary>
<content># RAG\n\nFull **Markdown** body.</content>
</wiki_page>
`, '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', `
<wiki_page>
<metadata><link>[[summary/empty|Empty page]]</link></metadata>
<summary>Summary fallback.</summary>
<content></content>
</wiki_page>
`, 'call-3')
assert.equal(refs[0]?.content, 'Summary fallback.')
})
test('ignores non-wiki tools and empty result sets', () => {
assert.deepEqual(parseWikiToolReferences('web_search', '<page>ignored</page>'), [])
assert.deepEqual(parseWikiToolReferences('wiki_search', '<search_results count="0" />'), [])
})
+87
View File
@@ -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 []
}
@@ -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<string, string> = {
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;
@@ -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/)