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:
@@ -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" />'), [])
|
||||
})
|
||||
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user