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 @@
{{ 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/)